claude-multiacc 2.0.59 → 2.0.61

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 (110) hide show
  1. package/README.md +19 -1
  2. package/bin/claude +78 -11
  3. package/bin/claude-accounts +3 -0
  4. package/bin/codex +65 -7
  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_mirror.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  41. package/lib/common.sh +15 -0
  42. package/lib/mcp_mirror.py +49 -0
  43. package/lib/mcp_mirror.sh +47 -0
  44. package/lib/mcp_registry.py +237 -10
  45. package/package.json +2 -2
  46. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  47. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  48. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  49. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  50. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  51. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  52. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  53. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  54. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  55. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  56. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  57. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  58. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  107. package/tests/run-tests.sh +146 -1
  108. package/tests/test_mcp_mirror.py +148 -0
  109. package/tests/test_mcp_mirror_args.py +129 -0
  110. 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)"
@@ -10108,7 +10251,9 @@ fi
10108
10251
  # reconciles with whatever `python3` is on PATH, and a runner Mac's is /usr/bin/python3.
10109
10252
  # Skipped silently where that interpreter is absent or already 3.11+ (CI is ubuntu).
10110
10253
  if [ -x /usr/bin/python3 ] && /usr/bin/python3 -c 'import sys; sys.exit(0 if sys.version_info < (3, 11) else 1)' 2>/dev/null; then
10111
- if /usr/bin/python3 "$REPO_DIR/tests/test_mcp_registry.py" >/dev/null 2>&1; then
10254
+ # Only this unit suite's disposable pools opt back in; legacy sections stay protected.
10255
+ if CLAUDE_MULTIACC_MCP_ADOPT=1 CODEX_MULTIACC_MCP_ADOPT=1 \
10256
+ /usr/bin/python3 "$REPO_DIR/tests/test_mcp_registry.py" >/dev/null 2>&1; then
10112
10257
  t_ok "MCP registry unit suite on stock python $(/usr/bin/python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])')"
10113
10258
  else
10114
10259
  t_fail "MCP registry unit suite on stock python" "/usr/bin/python3 tests/test_mcp_registry.py failed"
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+ """Stock mutations must not promote concurrent runner publications into the registry."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+ import unittest
7
+ from unittest.mock import patch
8
+
9
+ from test_mcp_registry import HAS_TOMLLIB, HTTP, R, RegistryCase, STDIO
10
+
11
+ APP = "appinspire-mcp"
12
+ MOTION = "motionsites"
13
+ V1 = {"type": "stdio", "command": "/releases/1000249/python", "args": ["launcher.pyc"], "env": {}}
14
+ V2 = {"type": "stdio", "command": "/releases/1000250/python", "args": ["launcher.pyc"], "env": {}}
15
+ V3 = {"type": "stdio", "command": "/bin/sh", "args": ["-p", "-c", "exec node server.mjs"], "env": {}}
16
+
17
+
18
+ class MirrorCases:
19
+ def setUp(self):
20
+ super().setUp()
21
+ if self.provider == "codex" and not HAS_TOMLLIB:
22
+ self.skipTest("Codex requires tomllib")
23
+ if self.replica:
24
+ (self.root / "sync-role").write_text("replica\n")
25
+
26
+ def publish(self, servers):
27
+ rc, _, err = self.run_cli(self.provider, "import-local", "--owner", "app-robot",
28
+ stdin=json.dumps({"mcpServers": servers}))
29
+ self.assertEqual(rc, 0, err)
30
+
31
+ def write_account(self, servers, projects=None):
32
+ if self.provider == "claude":
33
+ R._claude_save(self.acct(), {"mcpServers": servers, "projects": projects or {}})
34
+ else:
35
+ eff = R.Effective()
36
+ eff.servers = servers
37
+ text, notes = R.reconcile_codex_text("", eff)
38
+ self.assertFalse(notes)
39
+ (Path(self.acct()) / "config.toml").write_text(text)
40
+
41
+ def before(self):
42
+ rc, snapshot, err = self.run_cli(self.provider, "snapshot", "--account-dir", self.acct())
43
+ self.assertEqual(rc, 0, err)
44
+ path = self.root / "before.json"
45
+ path.write_text(snapshot)
46
+ return str(path)
47
+
48
+ def learn(self, before, name):
49
+ scope = ["--name", name] if name is not None else []
50
+ rc, _, err = self.run_cli(self.provider, "learn", "--account-dir", self.acct(),
51
+ "--before", before, *scope)
52
+ self.assertEqual(rc, 0, err)
53
+ return R._Target(str(self.root)).section
54
+
55
+ def test_named_add_ignores_delayed_runner_generation(self):
56
+ self.publish({APP: V1})
57
+ before = self.before()
58
+ self.publish({APP: V2})
59
+ # import-local commits G3 under the lock, then applies accounts outside it.
60
+ # Pause before apply: the client still sees G2, which differs from current G3.
61
+ with patch.object(R, "_apply_all", return_value=0):
62
+ self.publish({APP: V3})
63
+ self.write_account({APP: V2, MOTION: HTTP})
64
+ section = self.learn(before, MOTION)
65
+ self.assertEqual(set(section["mcpServers"]), {MOTION})
66
+ self.assertEqual(section["retired"] + section["retiredUser"], [])
67
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V3})
68
+ for account in ("acct-01", "acct-02"):
69
+ snapshot = R.snapshot(self.provider, self.acct(account))
70
+ self.assertEqual(snapshot["user"][APP]["command"], "/bin/sh")
71
+ self.assertEqual(snapshot["user"][MOTION]["url"], HTTP["url"])
72
+
73
+ def test_named_remove_does_not_promote_runner_retirement(self):
74
+ self.publish({APP: V1})
75
+ self.run_cli(self.provider, "add-json", MOTION, json.dumps(HTTP))
76
+ before = self.before()
77
+ self.publish({})
78
+ self.write_account({})
79
+ section = self.learn(before, MOTION)
80
+ self.assertEqual(section["mcpServers"], {})
81
+ self.assertEqual(section["retiredUser"], [MOTION])
82
+ self.assertEqual(section["retired"], [])
83
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["retired"], [APP])
84
+
85
+ def test_explicit_owner_override_and_removal_still_mirror(self):
86
+ self.publish({APP: V1})
87
+ before = self.before()
88
+ self.write_account({APP: HTTP})
89
+ section = self.learn(before, APP)
90
+ self.assertEqual(section["mcpServers"][APP]["url"], HTTP["url"])
91
+ self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V1})
92
+ before = self.before()
93
+ self.write_account({})
94
+ section = self.learn(before, APP)
95
+ self.assertEqual(section["mcpServers"], {})
96
+ self.assertEqual(section["retiredUser"], [APP])
97
+ self.assertEqual(section["retired"], [])
98
+
99
+ def test_bulk_import_excludes_external_updates_and_retirements(self):
100
+ self.publish({APP: V1, "old-runner-tool": STDIO})
101
+ self.run_cli(self.provider, "import-local", "--owner", "local",
102
+ stdin=json.dumps({"mcpServers": {"local-tool": V1}}))
103
+ before = self.before()
104
+ self.publish({APP: V2})
105
+ with patch.object(R, "_apply_all", return_value=0):
106
+ self.publish({APP: V3})
107
+ self.write_account({APP: V2, MOTION: HTTP, "local-tool": HTTP})
108
+ section = self.learn(before, None)
109
+ self.assertEqual(set(section["mcpServers"]), {MOTION, "local-tool"} if self.replica else {MOTION})
110
+ local = self.overlay()["owners"]["local"]["mcpServers"]["local-tool"]
111
+ self.assertEqual(local.get("url") if self.replica else local, HTTP["url"] if self.replica else V1)
112
+ self.assertEqual(section["retired"] + section["retiredUser"], [])
113
+
114
+ def test_named_project_changes_and_approvals_stay_scoped(self):
115
+ if self.provider != "claude":
116
+ self.skipTest("project MCP entries are Claude-only")
117
+ for name in (MOTION, "other"):
118
+ self.run_cli("claude", "add-json", name, json.dumps(STDIO), "--scope", "project",
119
+ "--project", "/project")
120
+ before = self.before()
121
+ self.write_account({}, {"/project": {"mcpServers": {MOTION: HTTP, "other": HTTP},
122
+ "enabledMcpjsonServers": [MOTION, "other"]}})
123
+ section = self.learn(before, MOTION)
124
+ project = section["projects"]["/project"]
125
+ self.assertEqual(project["mcpServers"], {MOTION: HTTP, "other": STDIO})
126
+ self.assertEqual(project["enabledMcpjsonServers"], [MOTION])
127
+ other = self.claude_doc("acct-02")["projects"]["/project"]
128
+ self.assertEqual(other["enabledMcpjsonServers"], [MOTION])
129
+
130
+
131
+ class ClaudeSourceTests(MirrorCases, RegistryCase):
132
+ provider, replica = "claude", False
133
+
134
+
135
+ class ClaudeReplicaTests(MirrorCases, RegistryCase):
136
+ provider, replica = "claude", True
137
+
138
+
139
+ class CodexSourceTests(MirrorCases, RegistryCase):
140
+ provider, replica = "codex", False
141
+
142
+
143
+ class CodexReplicaTests(MirrorCases, RegistryCase):
144
+ provider, replica = "codex", True
145
+
146
+
147
+ if __name__ == "__main__":
148
+ unittest.main()
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """MCP target parsing uses native verb options, without reading command payloads."""
3
+
4
+ from pathlib import Path
5
+ import subprocess
6
+ import tempfile
7
+ import unittest
8
+
9
+ from test_mcp_registry import R
10
+
11
+ HELPER = Path(__file__).resolve().parents[1] / "lib" / "mcp_mirror.sh"
12
+
13
+
14
+ class MutationNameTests(unittest.TestCase):
15
+ def parse(self, *args):
16
+ return subprocess.run(["/bin/bash", "-uc", '. "$1"; shift; mcp_mutation_name "$@"',
17
+ "mcp-target-test", str(HELPER), *args], capture_output=True, text=True)
18
+
19
+ def test_native_options_before_name(self):
20
+ cases = [
21
+ ("claude", "add", "-s", "user", "-t", "http", "motionsites", "https://example.test"),
22
+ ("claude", "add", "-suser", "-thttp", "motionsites", "https://example.test"),
23
+ ("claude", "add", "--scope=user", "--callback-port", "1234", "--client-id", "cid",
24
+ "--client-secret", "motionsites", "https://example.test"),
25
+ ("claude", "add", "-e", "A=1", "B=2", "-H", "X: 1", "Y: 2", "--", "motionsites", "echo"),
26
+ ("claude", "add", "-eA=1", "B=2", "--scope=user", "motionsites", "--", "echo", "--unknown"),
27
+ ("claude", "add-json", "-suser", "--client-secret", "motionsites", '{"command":"echo"}'),
28
+ ("claude", "remove", "--scope=user", "motionsites"),
29
+ ("codex", "add", "-c", "x=1", "--env", "A=1", "--url=https://example.test", "motionsites"),
30
+ ("codex", "add", "--enable", "x", "--disable=y", "--bearer-token-env-var", "MCP_TOKEN",
31
+ "--oauth-client-id", "cid", "--oauth-client-secret", "dummy", "--oauth-resource", "r",
32
+ "--oauth-client-registration", "dynamic", "motionsites"),
33
+ ("codex", "remove", "-cx=1", "--enable=x", "--disable", "y", "motionsites"),
34
+ ("codex", "add", "motionsites", "--", "node", "--env", "appinspire-mcp"),
35
+ ("claude", "add", "motionsites", "--", "node", "--help"),
36
+ ("codex", "add", "motionsites", "--help"),
37
+ ("claude", "remove", "motionsites", "-h"),
38
+ ("claude", "add", "--client-id", "--help", "motionsites", "https://example.test"),
39
+ ]
40
+ for args in cases:
41
+ with self.subTest(args=args):
42
+ result = self.parse(*args)
43
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "motionsites\n", ""))
44
+
45
+ def test_unknown_or_incomplete_options_never_become_a_broad_mirror(self):
46
+ cases = [
47
+ ("claude", "add", "--future-value", "appinspire-mcp", "motionsites"),
48
+ ("claude", "remove", "--scope"),
49
+ ("claude", "add", "-e", "A=1", "motionsites", "echo"),
50
+ ("claude", "add-json", "--client-secret=unexpected", "motionsites", "{}"),
51
+ ("codex", "add", "--unknown=value", "motionsites"),
52
+ ("codex", "remove", "--config"),
53
+ ("codex", "add", "--"),
54
+ ("claude", "add-from-claude-desktop"),
55
+ ]
56
+ for args in cases:
57
+ with self.subTest(args=args):
58
+ result = self.parse(*args)
59
+ self.assertNotEqual(result.returncode, 0)
60
+ self.assertEqual(result.stdout, "")
61
+
62
+ def test_help_without_a_target_has_a_distinct_quiet_status(self):
63
+ for provider in ("claude", "codex"):
64
+ for flag in ("-h", "--help"):
65
+ result = self.parse(provider, "add", flag)
66
+ self.assertEqual((result.returncode, result.stdout, result.stderr), (2, "", ""))
67
+
68
+ def run_shim(self, provider, args, native_exit=0):
69
+ with tempfile.TemporaryDirectory() as work:
70
+ root = Path(work)
71
+ pool, fakebin = root / "pool", root / "bin"
72
+ (pool / "acct-01").mkdir(parents=True)
73
+ fakebin.mkdir()
74
+ (pool / "accounts.json").write_text('{"version":1,"accounts":[{"id":"acct-01"}]}')
75
+ native = fakebin / provider
76
+ native.write_text('#!/bin/sh\nprintf "NATIVE\\n"\nprintf "%s\\n" "$@"\nexit "$FAKE_EXIT"\n')
77
+ native.chmod(0o755)
78
+ logger = fakebin / "mcp-python"
79
+ logger.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$MCP_TEST_LOG"\n'
80
+ 'case "$*" in *" snapshot "*) printf "{}\\n" ;; esac\n')
81
+ logger.chmod(0o755)
82
+ log = root / "mirror.log"
83
+ env = {"PATH": f"{fakebin}:/usr/bin:/bin", "HOME": work, "USER": "test", "LOGNAME": "test",
84
+ "TMPDIR": work, "FAKE_EXIT": str(native_exit), "MCP_TEST_LOG": str(log),
85
+ "CLAUDE_MULTIACC_PYTHON": str(logger), f"{provider.upper()}_ACCOUNTS_ROOT": str(pool),
86
+ f"{provider.upper()}_SHIM_ACTIVE": "1",
87
+ "CLAUDE_CONFIG_DIR" if provider == "claude" else "CODEX_HOME": str(pool / "acct-01")}
88
+ result = subprocess.run([str(HELPER.parents[1] / "bin" / provider), *args], env=env,
89
+ capture_output=True, text=True, timeout=10)
90
+ return result, log.read_text() if log.exists() else ""
91
+
92
+ def test_literal_help_global_value_still_mirrors(self):
93
+ args = ["--system-prompt", "--help", "mcp", "add-json", "--scope", "user", "motionsites",
94
+ '{"command":"echo","args":[]}']
95
+ result, log = self.run_shim("claude", args)
96
+ self.assertEqual((result.returncode, result.stderr), (0, ""))
97
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
98
+ self.assertIn("\nlearn\n", log)
99
+ self.assertIn("\n--name=motionsites\n", log)
100
+
101
+ def test_leading_dash_name_reaches_the_registry_parser(self):
102
+ args = ["mcp", "add", "--", "-probe-name", "echo"]
103
+ for provider in ("claude", "codex"):
104
+ result, log = self.run_shim(provider, args)
105
+ self.assertEqual((result.returncode, result.stderr), (0, ""))
106
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
107
+ words = log.splitlines()
108
+ tail = words[words.index("learn") + 1:]
109
+ parsed = R.build_parser().parse_args(["--root", "unused", "--provider", provider, "learn", *tail])
110
+ self.assertEqual(parsed.name, "-probe-name")
111
+
112
+ def test_help_is_quiet_native_passthrough(self):
113
+ for provider in ("claude", "codex"):
114
+ result, log = self.run_shim(provider, ["mcp", "add", "--help"])
115
+ self.assertEqual((result.returncode, result.stderr, log), (0, "", ""))
116
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", "mcp", "add", "--help"])
117
+
118
+ def test_unknown_options_keep_native_argv_and_exit_without_mirroring(self):
119
+ args = ["mcp", "add", "--future-value", "appinspire-mcp", "motionsites"]
120
+ for provider in ("claude", "codex"):
121
+ for native_exit in (0, 7):
122
+ result, log = self.run_shim(provider, args, native_exit)
123
+ self.assertEqual((result.returncode, log), (native_exit, ""))
124
+ self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
125
+ self.assertEqual("change not mirrored" in result.stderr, native_exit == 0)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ unittest.main()