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.
- package/README.md +14 -1
- package/bin/claude +66 -10
- package/bin/claude-accounts +3 -0
- package/bin/codex +54 -5
- package/bin/codex-accounts +2 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/common.sh +15 -0
- package/lib/mcp_registry.py +230 -8
- package/package.json +1 -1
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +143 -0
- package/tests/test_mcp_registry.py +234 -3
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -49,6 +49,18 @@ mkdir -p "$ACC/tmp" "$FAKEBIN"
|
|
|
49
49
|
cat > "$FAKEBIN/claude" <<'EOF'
|
|
50
50
|
#!/usr/bin/env bash
|
|
51
51
|
# fake real claude for tests (not a multiacc shim)
|
|
52
|
+
# Global options may precede the subcommand, as commander allows (the operator's alias
|
|
53
|
+
# `claude --dangerously-skip-permissions`, the shim's own `--model X`): the mcp stand-in
|
|
54
|
+
# skips the two the suite types, and nothing else — `--resume mcp add x` is no mcp run.
|
|
55
|
+
_fa=("$@")
|
|
56
|
+
while [ "${#_fa[@]}" -gt 0 ]; do
|
|
57
|
+
case "${_fa[0]}" in
|
|
58
|
+
--dangerously-skip-permissions) _fa=("${_fa[@]:1}") ;;
|
|
59
|
+
--model) _fa=("${_fa[@]:2}") ;;
|
|
60
|
+
*) break ;;
|
|
61
|
+
esac
|
|
62
|
+
done
|
|
63
|
+
if [ "${_fa[0]:-}" = "mcp" ] && [ "${_fa[0]:-}" != "${1:-}" ]; then set -- "${_fa[@]}"; fi
|
|
52
64
|
if [ "${1:-}" = "mcp" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
53
65
|
# A stand-in for the real `claude mcp add|add-json|remove`: `-s user` edits the
|
|
54
66
|
# config dir's .claude.json mcpServers, the default `local` scope edits
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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()
|