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
package/README.md
CHANGED
|
@@ -568,7 +568,20 @@ claude-accounts mcp apply # re-apply now (repair drift)
|
|
|
568
568
|
outcome into the registry and every other account (`learn`). This includes a command run
|
|
569
569
|
from **inside** a pooled session (an agent's Bash tool, `npx appinspire-mcp install` run by
|
|
570
570
|
an agent): the session's config dir is a pool account, so the same mirror applies. A
|
|
571
|
-
config dir outside the pool is plain passthrough.
|
|
571
|
+
config dir outside the pool is plain passthrough. Global options in front of `mcp` are
|
|
572
|
+
looked past the way the real parser does (`alias claude='claude
|
|
573
|
+
--dangerously-skip-permissions'` puts one before every typed `claude mcp add`; until
|
|
574
|
+
2026-10-22 that kept motionsites in 2 of 11 accounts).
|
|
575
|
+
- **Adoption**: whatever reached an account by another route — the real binary run
|
|
576
|
+
directly, a hand edit, another tool's installer — is adopted by the next reconcile: a
|
|
577
|
+
server (user scope or a project entry) or a project's `.mcp.json` approval that no section
|
|
578
|
+
names, live or retired, joins the registry (a replica: its overlay) and so every account.
|
|
579
|
+
Several definitions of one name: the one most accounts hold wins, then the newest config
|
|
580
|
+
file. Every limits pass reconciles all accounts this way, so an idle account catches up too.
|
|
581
|
+
Never adopted: anything the pool itself once wrote into that account (`<acct>/.mcp-managed.json`
|
|
582
|
+
— so a stray synced entry dropped without a tombstone, or a vanished overlay owner's
|
|
583
|
+
server, stays out of the synced registry), tombstoned names, and nothing at all with
|
|
584
|
+
`CLAUDE_MULTIACC_MCP_ADOPT=0` / `CODEX_MULTIACC_MCP_ADOPT=0`.
|
|
572
585
|
- **Runner-managed servers**: on app-robot fleet Macs the runner publishes `appinspire-mcp` into
|
|
573
586
|
each Mac's machine-local overlay with that Mac's own paths. Never `mcp add` it to the synced
|
|
574
587
|
registry (directly, through a mirrored `claude|codex mcp add`, or via `appinspire-mcp install`):
|
package/bin/claude
CHANGED
|
@@ -167,14 +167,71 @@ mcp_oauth_ensure() { # $1 acct dir — silent, never changes the exit status
|
|
|
167
167
|
# account against a snapshot taken just before). The real CLI's own parser and writer do
|
|
168
168
|
# the work; the shim only copies the outcome. Exits with the real binary's status; a
|
|
169
169
|
# mirror failure is one hint line. Returns 1 when this run is not such a command.
|
|
170
|
+
# The index of the first positional argument as the real CLI's parser (commander) finds
|
|
171
|
+
# it: global options may come first, in any number. The operator's `alias claude='claude
|
|
172
|
+
# --dangerously-skip-permissions'` puts one in front of every `claude mcp add` typed at a
|
|
173
|
+
# prompt, and the scoped-limit fallback prepends `--model X`; the mirror once looked at $1
|
|
174
|
+
# alone, so on 2026-10-22 motionsites (and playwright, inspo) sat in the one account a
|
|
175
|
+
# typed add ran under. A value option skips its value; an optional-value one the next
|
|
176
|
+
# word unless it is an option; a variadic one every word up to the next option. `--`, or
|
|
177
|
+
# a positional after `--` counts (commander's own rule). No positional: status 1. Options
|
|
178
|
+
# of claude 2.1.308 (`claude --help`; hidden ones left out on purpose): an unknown one
|
|
179
|
+
# counts as a flag — at worst its value is taken for the positional, which is then no
|
|
180
|
+
# `mcp`, the run is not mirrored, and the registry's adoption picks the server up instead.
|
|
181
|
+
CLI_VALUE_OPTS=" --agent --agents --append-system-prompt --append-system-prompt-file \
|
|
182
|
+
--autocompact --debug-file --effort --environment --fallback-model --input-format \
|
|
183
|
+
--json-schema --max-budget-usd --model -n --name --output-format --permission-mode \
|
|
184
|
+
--permission-prompts --permission-prompt-tool --plugin-dir --plugin-url \
|
|
185
|
+
--remote-control-session-name-prefix --session-id --setting-sources --settings \
|
|
186
|
+
--system-prompt --system-prompt-file --system-prompt-snapshot "
|
|
187
|
+
CLI_OPTIONAL_OPTS=" -d --debug --cloud --from-pr --prompt-suggestions --remote-control --rc \
|
|
188
|
+
--remote -r --resume --teleport -w --worktree "
|
|
189
|
+
CLI_VARIADIC_OPTS=" --add-dir --allowedTools --allowed-tools --betas --disallowedTools \
|
|
190
|
+
--disallowed-tools --file --mcp-config --tools "
|
|
191
|
+
cli_positional_at() { # argv -> prints the index of its first positional argument
|
|
192
|
+
local i=1 a nx
|
|
193
|
+
while [ "$i" -le "$#" ]; do
|
|
194
|
+
a="${!i}"
|
|
195
|
+
case "$a" in
|
|
196
|
+
--) i=$((i + 1)); [ "$i" -le "$#" ] || return 1; printf '%s\n' "$i"; return 0 ;;
|
|
197
|
+
--*=*) i=$((i + 1)); continue ;;
|
|
198
|
+
-?*) ;;
|
|
199
|
+
*) printf '%s\n' "$i"; return 0 ;;
|
|
200
|
+
esac
|
|
201
|
+
i=$((i + 1))
|
|
202
|
+
case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
|
|
203
|
+
case "$CLI_OPTIONAL_OPTS" in
|
|
204
|
+
*" $a "*) nx="${!i:-}"; case "$nx" in ''|-*) ;; *) i=$((i + 1)) ;; esac; continue ;;
|
|
205
|
+
esac
|
|
206
|
+
case "$CLI_VARIADIC_OPTS" in
|
|
207
|
+
*" $a "*)
|
|
208
|
+
while [ "$i" -le "$#" ]; do
|
|
209
|
+
nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
|
|
210
|
+
done ;;
|
|
211
|
+
esac
|
|
212
|
+
done
|
|
213
|
+
return 1
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
# Whether argv is `claude [global options] mcp add|add-json|add-from-claude-desktop|remove …`.
|
|
217
|
+
# The exact word -p/--print (or --bg/--background) anywhere before a `--` puts the client in
|
|
218
|
+
# print/background mode, which never runs the subcommand.
|
|
219
|
+
mcp_mutation_argv() {
|
|
220
|
+
local i j a
|
|
221
|
+
for a in "$@"; do
|
|
222
|
+
case "$a" in --) break ;; -p|--print|--bg|--background) return 1 ;; esac
|
|
223
|
+
done
|
|
224
|
+
i="$(cli_positional_at "$@")" || return 1
|
|
225
|
+
[ "${!i}" = "mcp" ] || return 1
|
|
226
|
+
j=$((i + 1))
|
|
227
|
+
case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) return 0 ;; esac
|
|
228
|
+
return 1
|
|
229
|
+
}
|
|
230
|
+
|
|
170
231
|
mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
|
|
171
|
-
local d="$1" snap="" rc
|
|
232
|
+
local d="$1" snap="" rc
|
|
172
233
|
shift
|
|
173
|
-
|
|
174
|
-
# CLI accepts in front of any subcommand): look past it.
|
|
175
|
-
if [ "$verb" = "--model" ]; then verb="${3:-}"; sub="${4:-}"; fi
|
|
176
|
-
[ "$verb" = "mcp" ] || return 1
|
|
177
|
-
case "$sub" in add|add-json|remove) ;; *) return 1 ;; esac
|
|
234
|
+
mcp_mutation_argv "$@" || return 1
|
|
178
235
|
[ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
|
|
179
236
|
mcp_ensure "$d"
|
|
180
237
|
snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
|
|
@@ -207,9 +264,8 @@ mcp_nested_mirror() {
|
|
|
207
264
|
[ "${CLAUDE_MULTIACC_DISABLE:-0}" = "1" ] && return 1
|
|
208
265
|
[ "${CLAUDE_MULTIACC_MCP:-1}" = "0" ] && return 1
|
|
209
266
|
[ -n "${CLAUDE_CONFIG_DIR:-}" ] || return 1
|
|
210
|
-
local
|
|
211
|
-
|
|
212
|
-
[ "$verb" = "mcp" ] || return 1
|
|
267
|
+
local cfg="${CLAUDE_CONFIG_DIR%/}" d=""
|
|
268
|
+
mcp_mutation_argv "$@" || return 1
|
|
213
269
|
# A DIRECT child of the pool root, by its given spelling or its physical one (an
|
|
214
270
|
# adopted account is a symlink whose target is the operator's ~/.claude).
|
|
215
271
|
case "$cfg" in
|
|
@@ -2522,7 +2578,7 @@ export CLAUDE_SHIM_ACTIVE=1
|
|
|
2522
2578
|
|
|
2523
2579
|
# A `claude mcp add|add-json|remove` is run as a child under the picked account and
|
|
2524
2580
|
# mirrored to every other one (exits inside); anything else falls through to exec.
|
|
2525
|
-
if
|
|
2581
|
+
if mcp_mutation_argv "$@"; then
|
|
2526
2582
|
export CLAUDE_CONFIG_DIR="$pick"
|
|
2527
2583
|
tok="$PICK_TOKEN"
|
|
2528
2584
|
[ -n "$tok" ] && export CLAUDE_CODE_OAUTH_TOKEN="$tok"
|
package/bin/claude-accounts
CHANGED
|
@@ -2963,6 +2963,9 @@ PYEOF
|
|
|
2963
2963
|
# lock while a consumer reads its output.
|
|
2964
2964
|
limits_lock_release
|
|
2965
2965
|
trap - EXIT
|
|
2966
|
+
# Every account's MCP servers, adopting what one holds that the registry does not know
|
|
2967
|
+
# (lib/common.sh) — before the push below, which carries the registry to the peers.
|
|
2968
|
+
mcp_registry_pass
|
|
2966
2969
|
# Whoever just refreshed is the only machine that CAN — pass it on before the
|
|
2967
2970
|
# report, so a blind peer stops picking drained accounts within one cycle.
|
|
2968
2971
|
limits_distribute
|
package/bin/codex
CHANGED
|
@@ -119,11 +119,60 @@ mcp_ensure() { # $1 acct dir — silent, never changes the exit status
|
|
|
119
119
|
# snapshot taken just before). The real CLI's own parser and writer do the work; the shim
|
|
120
120
|
# only copies the outcome. Exits with the real binary's status; a mirror failure is one
|
|
121
121
|
# hint line. Returns 1 when this run is not such a command.
|
|
122
|
+
# The index of the first positional argument as the real CLI's parser (clap) finds it:
|
|
123
|
+
# global options may come first, in any number. The operator's `alias codex='codex
|
|
124
|
+
# --dangerously-bypass-approvals-and-sandbox'` puts one in front of every `codex mcp add`
|
|
125
|
+
# typed at a prompt, and the mirror once looked at $1 alone (bin/claude met the same on
|
|
126
|
+
# 2026-10-22). A value option skips its value (attached `-mX`/`--model=X` is one word); a
|
|
127
|
+
# variadic one every word up to the next option. `--`, or no positional: status 1.
|
|
128
|
+
# Options of codex-cli 0.163 (`codex --help`); an unknown one counts as a flag.
|
|
129
|
+
CLI_VALUE_OPTS=" -c --config --enable --disable --remote --remote-auth-token-env -m --model \
|
|
130
|
+
--local-provider -p --profile -s --sandbox -C --cd --add-dir -a --ask-for-approval "
|
|
131
|
+
CLI_VARIADIC_OPTS=" -i --image "
|
|
132
|
+
cli_positional_at() { # argv -> prints the index of its first positional argument
|
|
133
|
+
local i=1 a nx
|
|
134
|
+
while [ "$i" -le "$#" ]; do
|
|
135
|
+
a="${!i}"
|
|
136
|
+
case "$a" in
|
|
137
|
+
--) return 1 ;;
|
|
138
|
+
--*=*) i=$((i + 1)); continue ;;
|
|
139
|
+
-?*) ;;
|
|
140
|
+
*) printf '%s\n' "$i"; return 0 ;;
|
|
141
|
+
esac
|
|
142
|
+
i=$((i + 1))
|
|
143
|
+
case "$CLI_VALUE_OPTS" in *" $a "*) i=$((i + 1)); continue ;; esac
|
|
144
|
+
case "$CLI_VARIADIC_OPTS" in
|
|
145
|
+
*" $a "*)
|
|
146
|
+
while [ "$i" -le "$#" ]; do
|
|
147
|
+
nx="${!i}"; case "$nx" in -*) break ;; esac; i=$((i + 1))
|
|
148
|
+
done ;;
|
|
149
|
+
esac
|
|
150
|
+
done
|
|
151
|
+
return 1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Whether argv is `codex [global options] mcp add|remove …`.
|
|
155
|
+
mcp_mutation_argv() {
|
|
156
|
+
local i j
|
|
157
|
+
i="$(cli_positional_at "$@")" || return 1
|
|
158
|
+
[ "${!i}" = "mcp" ] || return 1
|
|
159
|
+
j=$((i + 1))
|
|
160
|
+
# `mcp` takes options of its own before its subcommand: -c/--config, --enable, --disable
|
|
161
|
+
while :; do
|
|
162
|
+
case "${!j:-}" in
|
|
163
|
+
-c|--config|--enable|--disable) j=$((j + 2)) ;;
|
|
164
|
+
--*=*|-c?*) j=$((j + 1)) ;;
|
|
165
|
+
*) break ;;
|
|
166
|
+
esac
|
|
167
|
+
done
|
|
168
|
+
case "${!j:-}" in add|remove) return 0 ;; esac
|
|
169
|
+
return 1
|
|
170
|
+
}
|
|
171
|
+
|
|
122
172
|
mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
|
|
123
173
|
local d="$1" snap="" rc
|
|
124
174
|
shift
|
|
125
|
-
|
|
126
|
-
case "${2:-}" in add|remove) ;; *) return 1 ;; esac
|
|
175
|
+
mcp_mutation_argv "$@" || return 1
|
|
127
176
|
[ "${CODEX_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
|
|
128
177
|
mcp_ensure "$d"
|
|
129
178
|
snap="$(mktemp "${TMPDIR:-/tmp}/codex-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
|
|
@@ -156,8 +205,8 @@ mcp_nested_mirror() {
|
|
|
156
205
|
[ "${CODEX_MULTIACC_DISABLE:-0}" = "1" ] && return 1
|
|
157
206
|
[ "${CODEX_MULTIACC_MCP:-1}" = "0" ] && return 1
|
|
158
207
|
[ -n "${CODEX_HOME:-}" ] || return 1
|
|
159
|
-
local
|
|
160
|
-
|
|
208
|
+
local cfg="${CODEX_HOME%/}" d=""
|
|
209
|
+
mcp_mutation_argv "$@" || return 1
|
|
161
210
|
# A DIRECT child of the pool root, by its given spelling or its physical one (an
|
|
162
211
|
# adopted account is a symlink whose target is the operator's ~/.claude).
|
|
163
212
|
case "$cfg" in
|
|
@@ -1526,7 +1575,7 @@ export CODEX_SHIM_ACTIVE=1
|
|
|
1526
1575
|
|
|
1527
1576
|
# A `codex mcp add|remove` is run as a child under the picked account and mirrored to
|
|
1528
1577
|
# every other one (exits inside); anything else falls through to exec.
|
|
1529
|
-
if
|
|
1578
|
+
if mcp_mutation_argv "$@"; then
|
|
1530
1579
|
share_state_index "$pick"
|
|
1531
1580
|
export CODEX_HOME="$pick"
|
|
1532
1581
|
mcp_mirror_run "$pick" "$@" || true
|
package/bin/codex-accounts
CHANGED
|
@@ -1846,6 +1846,8 @@ PYEOF
|
|
|
1846
1846
|
# lock while a consumer reads its output.
|
|
1847
1847
|
codex_limits_lock_release
|
|
1848
1848
|
trap - EXIT
|
|
1849
|
+
# Every account's MCP servers, adopting what one holds that the registry does not know.
|
|
1850
|
+
mcp_registry_pass
|
|
1849
1851
|
# This pool's model catalog, once its copy is an hour old (lib/common.sh).
|
|
1850
1852
|
model_catalog_backstop
|
|
1851
1853
|
# The refresher fails open per account; a NON-zero status means the pass itself
|
|
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/lib/common.sh
CHANGED
|
@@ -771,6 +771,21 @@ seed_mcp_registry() { # $1 = acct dir
|
|
|
771
771
|
return 0
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
# Once per scheduled limits pass: reconcile EVERY account, which first adopts what any
|
|
775
|
+
# one of them holds that the registry does not know (lib/mcp_registry.py adopt) — the
|
|
776
|
+
# backstop for a server added where the shims' mirror never saw it, reaching accounts no
|
|
777
|
+
# session launched on since. Unlike the shims' pre-exec apply it also runs before this
|
|
778
|
+
# pool has a registry at all. Inline, quiet, fail-open: a pass never dies on it.
|
|
779
|
+
mcp_registry_pass() {
|
|
780
|
+
local off
|
|
781
|
+
if [ "$MULTIACC_PROVIDER" = "codex" ]; then off="${CODEX_MULTIACC_MCP:-1}"; else off="${CLAUDE_MULTIACC_MCP:-1}"; fi
|
|
782
|
+
[ "$off" = "0" ] && return 0
|
|
783
|
+
[ -f "$LIB_DIR/mcp_registry.py" ] || return 0
|
|
784
|
+
"$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" apply \
|
|
785
|
+
--all --fail-open --quiet >/dev/null 2>&1 </dev/null || true
|
|
786
|
+
return 0
|
|
787
|
+
}
|
|
788
|
+
|
|
774
789
|
seed_account_dir() { # $1 = acct dir
|
|
775
790
|
local d="$1"
|
|
776
791
|
mkdir -p "$d"
|
package/lib/mcp_registry.py
CHANGED
|
@@ -33,8 +33,12 @@ Reconcile rules, the same for both providers:
|
|
|
33
33
|
any more" needs to hold fleet-wide; a name in `retiredUser` (a learned stock
|
|
34
34
|
`claude mcp remove -s user`, or `mcp remove --user-only`) leaves project
|
|
35
35
|
entries alone, exactly as the client itself did;
|
|
36
|
-
*
|
|
37
|
-
|
|
36
|
+
* a server an account holds that no section names, live or retired, is ADOPTED
|
|
37
|
+
into the registry before the reconcile (`adopt`), and so reaches every account:
|
|
38
|
+
some route around the shims' mirror put it there (2026-10-22: the operator's
|
|
39
|
+
`alias claude='claude --dangerously-skip-permissions'` hid every typed
|
|
40
|
+
`claude mcp add` from it, and motionsites sat in 2 of 11 accounts). Kill switch
|
|
41
|
+
`<PROVIDER>_MULTIACC_MCP_ADOPT=0`;
|
|
38
42
|
* project-scoped entries (`projects` in the registry) exist for claude only:
|
|
39
43
|
Codex 0.156 reads no per-project config at all.
|
|
40
44
|
|
|
@@ -73,6 +77,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
73
77
|
REGISTRY_FILE = "mcp-servers.json"
|
|
74
78
|
OVERLAY_FILE = "mcp-servers.local.json"
|
|
75
79
|
STAMP_FILE = ".mcp-applied"
|
|
80
|
+
#: Every name the pool itself ever wrote into the account (from any section): adoption
|
|
81
|
+
#: never takes one of those back, so a server dropped from a section without a tombstone
|
|
82
|
+
#: (README's recovery for a stray synced entry) or one whose overlay owner vanished is a
|
|
83
|
+
#: stale copy, not a new server — app-robot's machine-specific appinspire-mcp must never
|
|
84
|
+
#: reach the synced registry that way.
|
|
85
|
+
MANAGED_FILE = ".mcp-managed.json"
|
|
76
86
|
LOCK_DIR = os.path.join(".locks", "mcp")
|
|
77
87
|
PROVIDERS = ("claude", "codex")
|
|
78
88
|
CONFIG_FILE = {"claude": ".claude.json", "codex": "config.toml"}
|
|
@@ -168,13 +178,14 @@ def _log(root, message):
|
|
|
168
178
|
class _Lock:
|
|
169
179
|
"""mkdir lock, the same shape as lib/common.sh mutate_lock (30 s stale)."""
|
|
170
180
|
|
|
171
|
-
def __init__(self, root):
|
|
181
|
+
def __init__(self, root, wait=60):
|
|
172
182
|
self.path = os.path.join(root, LOCK_DIR)
|
|
173
183
|
self.held = False
|
|
184
|
+
self.wait = wait
|
|
174
185
|
|
|
175
186
|
def __enter__(self):
|
|
176
187
|
os.makedirs(os.path.dirname(self.path), exist_ok=True)
|
|
177
|
-
deadline = time.time() +
|
|
188
|
+
deadline = time.time() + self.wait
|
|
178
189
|
while True:
|
|
179
190
|
try:
|
|
180
191
|
os.mkdir(self.path)
|
|
@@ -864,18 +875,215 @@ def snapshot_codex(account_dir):
|
|
|
864
875
|
|
|
865
876
|
# ---------------------------------------------------------------- reconcile
|
|
866
877
|
|
|
878
|
+
def load_managed(account_dir):
|
|
879
|
+
"""{"user": set(names), "projects": {path: set(names)}} — the account's ledger."""
|
|
880
|
+
doc = _read_json(os.path.join(account_dir, MANAGED_FILE), {})
|
|
881
|
+
doc = doc if isinstance(doc, dict) else {}
|
|
882
|
+
user = doc.get("user") if isinstance(doc.get("user"), list) else []
|
|
883
|
+
projects = doc.get("projects") if isinstance(doc.get("projects"), dict) else {}
|
|
884
|
+
return {"user": {str(n) for n in user},
|
|
885
|
+
"projects": {str(p): {str(n) for n in names} for p, names in projects.items()
|
|
886
|
+
if isinstance(names, list)}}
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def record_managed(provider, account_dir, eff):
|
|
890
|
+
"""Add what this reconcile wrote to the ledger (it only ever grows)."""
|
|
891
|
+
managed = load_managed(account_dir)
|
|
892
|
+
user = managed["user"] | set(eff.servers)
|
|
893
|
+
projects = {path: set(names) for path, names in managed["projects"].items()}
|
|
894
|
+
if provider == "claude":
|
|
895
|
+
for path, spec in eff.projects.items():
|
|
896
|
+
names = set(spec.get("mcpServers") or {})
|
|
897
|
+
if names:
|
|
898
|
+
projects[path] = projects.get(path, set()) | names
|
|
899
|
+
if user == managed["user"] and projects == managed["projects"]:
|
|
900
|
+
return
|
|
901
|
+
try:
|
|
902
|
+
_atomic_write(os.path.join(account_dir, MANAGED_FILE), json.dumps(
|
|
903
|
+
{"user": sorted(user), "projects": {p: sorted(n) for p, n in sorted(projects.items())}},
|
|
904
|
+
indent=2) + "\n")
|
|
905
|
+
except OSError:
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
|
|
867
909
|
def apply_account(root, provider, account_dir):
|
|
868
910
|
eff = effective(load_registry(root), load_overlay(root))
|
|
869
911
|
if provider == "claude":
|
|
870
912
|
state = apply_claude(account_dir, eff)
|
|
871
913
|
else:
|
|
872
914
|
state = apply_codex(account_dir, eff)
|
|
915
|
+
record_managed(provider, account_dir, eff)
|
|
873
916
|
write_stamp(root, provider, account_dir)
|
|
874
917
|
return state
|
|
875
918
|
|
|
876
919
|
|
|
920
|
+
def _adopt_enabled(provider):
|
|
921
|
+
return os.environ.get(f"{provider.upper()}_MULTIACC_MCP_ADOPT", "1") != "0"
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _unknown(eff, snap, managed=None):
|
|
925
|
+
"""What one account's snapshot holds that no section names, live or retired:
|
|
926
|
+
[(scope, path, name, block)] (path None for user scope). A project's `.mcp.json`
|
|
927
|
+
approvals are NOT adopted: one account's click would override another's rejection
|
|
928
|
+
(`disabledMcpjsonServers`) everywhere — only a mirrored `claude mcp` command carries one."""
|
|
929
|
+
managed = managed or {"user": set(), "projects": {}}
|
|
930
|
+
found = []
|
|
931
|
+
for name, block in (snap.get("user") or {}).items():
|
|
932
|
+
if not isinstance(block, dict):
|
|
933
|
+
continue
|
|
934
|
+
if name in eff.servers or name in eff.retired or name in eff.retired_user \
|
|
935
|
+
or name in managed["user"]:
|
|
936
|
+
continue
|
|
937
|
+
found.append(("user", None, name, block))
|
|
938
|
+
if snap.get("provider") != "claude":
|
|
939
|
+
return found
|
|
940
|
+
for path, entry in (snap.get("projects") or {}).items():
|
|
941
|
+
spec = eff.projects.get(path) or {}
|
|
942
|
+
known = set(spec.get("mcpServers") or {}) | set(spec.get("retired") or []) \
|
|
943
|
+
| eff.retired | managed["projects"].get(path, set())
|
|
944
|
+
for name, block in (entry.get("mcpServers") or {}).items():
|
|
945
|
+
if isinstance(block, dict) and name not in known:
|
|
946
|
+
found.append(("project", path, name, block))
|
|
947
|
+
return found
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _still_unknown(eff, scope, path, name):
|
|
951
|
+
if scope == "user":
|
|
952
|
+
return not (name in eff.servers or name in eff.retired or name in eff.retired_user)
|
|
953
|
+
spec = eff.projects.get(path) or {}
|
|
954
|
+
return not (name in (spec.get("mcpServers") or {}) or name in (spec.get("retired") or [])
|
|
955
|
+
or name in eff.retired)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _config_mtime(provider, account_dir):
|
|
959
|
+
try:
|
|
960
|
+
return os.stat(os.path.join(account_dir, CONFIG_FILE[provider])).st_mtime
|
|
961
|
+
except OSError:
|
|
962
|
+
return 0.0
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _codex_faithful(name, table):
|
|
966
|
+
"""Whether the registry's codex translation gives this table back as it is (bar the
|
|
967
|
+
startup timeout default it adds to a stdio server): only then is a codex table adopted,
|
|
968
|
+
because the reconcile then rewrites the user's own table in that translation."""
|
|
969
|
+
try:
|
|
970
|
+
again, _notes = codex_block(name, codex_to_claude(name, table))
|
|
971
|
+
except RegistryError:
|
|
972
|
+
return False
|
|
973
|
+
if again is None:
|
|
974
|
+
return False
|
|
975
|
+
again = dict(again)
|
|
976
|
+
if "startup_timeout_sec" not in table and again.get("startup_timeout_sec") == 60:
|
|
977
|
+
again.pop("startup_timeout_sec")
|
|
978
|
+
if not table.get("args") and again.get("args") == []:
|
|
979
|
+
again.pop("args", None)
|
|
980
|
+
return again == {k: v for k, v in table.items()}
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _carries_values(block):
|
|
984
|
+
"""A block whose env or headers hold literal values — a token, an API key — stays on
|
|
985
|
+
this machine: the synced registry is a plain file pushed to every peer."""
|
|
986
|
+
for key in ("env", "headers"):
|
|
987
|
+
values = block.get(key)
|
|
988
|
+
if isinstance(values, dict) and any(str(v) and not str(v).startswith("${")
|
|
989
|
+
for v in values.values()):
|
|
990
|
+
return True
|
|
991
|
+
return False
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def adopt(root, provider, account_dirs, dry=False, lock_wait=60):
|
|
995
|
+
"""Take every server these accounts hold that no section names into the registry
|
|
996
|
+
(on a replica: this machine's overlay, like any learned change). The mirror
|
|
997
|
+
(`learn`) sees only a `mcp add` the shim recognised; anything else — a global
|
|
998
|
+
option in front of `mcp` (the operator's aliases), the real binary run directly, a
|
|
999
|
+
hand edit, another tool's installer — left the server in the one account it ran
|
|
1000
|
+
under, for ever. Several accounts holding different definitions of one name: the
|
|
1001
|
+
one most of them hold wins, then the newest config file. Returns the change list
|
|
1002
|
+
(dry: the names it would adopt, nothing written)."""
|
|
1003
|
+
if not account_dirs or not _adopt_enabled(provider):
|
|
1004
|
+
return []
|
|
1005
|
+
eff = effective(load_registry(root), load_overlay(root))
|
|
1006
|
+
variants = {} # (scope, path, name) -> {canonical json: [count, newest mtime, block]}
|
|
1007
|
+
# Every account votes, whichever one this run reconciles: a single-account apply (the
|
|
1008
|
+
# shim's, before exec) must choose what `apply --all` would, never its own stale copy.
|
|
1009
|
+
voters = list(dict.fromkeys(list(account_dirs) + manifest_account_dirs(root)))
|
|
1010
|
+
for account_dir in voters:
|
|
1011
|
+
try:
|
|
1012
|
+
snap = snapshot(provider, account_dir)
|
|
1013
|
+
except RegistryError:
|
|
1014
|
+
continue # apply reports an unreadable config itself
|
|
1015
|
+
mtime = _config_mtime(provider, account_dir)
|
|
1016
|
+
for scope, path, name, block in _unknown(eff, snap, load_managed(account_dir)):
|
|
1017
|
+
slot = variants.setdefault((scope, path, name), {})
|
|
1018
|
+
key = json.dumps(block, sort_keys=True)
|
|
1019
|
+
count = slot.setdefault(key, [0, 0.0, block])
|
|
1020
|
+
count[0] += 1
|
|
1021
|
+
count[1] = max(count[1], mtime)
|
|
1022
|
+
if not variants or dry:
|
|
1023
|
+
return sorted(name for _scope, _path, name in variants) # dry: what would be adopted
|
|
1024
|
+
changes = []
|
|
1025
|
+
synced_changed = local_changed = False
|
|
1026
|
+
with _Lock(root, wait=lock_wait):
|
|
1027
|
+
target = _Target(root)
|
|
1028
|
+
local_overlay = target.overlay if target.replica else load_overlay(root)
|
|
1029
|
+
eff = effective(load_registry(root), load_overlay(root)) # what holds under the lock
|
|
1030
|
+
for (scope, path, name), slot in sorted(variants.items(), key=lambda kv: (
|
|
1031
|
+
kv[0][0], kv[0][1] or "", kv[0][2])):
|
|
1032
|
+
if not _still_unknown(eff, scope, path, name):
|
|
1033
|
+
continue # named meanwhile (a concurrent add, another machine's push)
|
|
1034
|
+
count, _mtime, block = max(slot.values(), key=lambda v: (v[0], v[1]))
|
|
1035
|
+
if provider == "codex" and not _codex_faithful(name, block):
|
|
1036
|
+
_warn(f'not adopted: "{name}" — the registry cannot express its codex table '
|
|
1037
|
+
f"as it is; register it with: codex-accounts mcp add")
|
|
1038
|
+
continue
|
|
1039
|
+
try:
|
|
1040
|
+
learned = block if provider == "claude" else codex_to_claude(name, block)
|
|
1041
|
+
validate_block(name, learned)
|
|
1042
|
+
except RegistryError as exc:
|
|
1043
|
+
_warn(f"not adopted: {exc}")
|
|
1044
|
+
continue
|
|
1045
|
+
where = f" (project {path})" if path else ""
|
|
1046
|
+
if _carries_values(learned) and not target.replica:
|
|
1047
|
+
local = local_overlay.setdefault("owners", {}).setdefault(
|
|
1048
|
+
"local", _normalize_section({}))
|
|
1049
|
+
_upsert(local, name, learned, scope, path)
|
|
1050
|
+
local_changed = True
|
|
1051
|
+
where += " (this machine only: it carries env/header values)"
|
|
1052
|
+
else:
|
|
1053
|
+
_upsert(target.section, name, learned, scope, path)
|
|
1054
|
+
synced_changed = True
|
|
1055
|
+
if len(slot) > 1:
|
|
1056
|
+
changes.append(f'"{name}"{where} (the definition {count} of '
|
|
1057
|
+
f'{sum(v[0] for v in slot.values())} accounts hold)')
|
|
1058
|
+
else:
|
|
1059
|
+
changes.append(f'"{name}"{where}')
|
|
1060
|
+
if synced_changed:
|
|
1061
|
+
target.save()
|
|
1062
|
+
if local_changed:
|
|
1063
|
+
save_overlay(root, local_overlay)
|
|
1064
|
+
if changes:
|
|
1065
|
+
_log(root, f"adopt {', '.join(changes)} into {target.where}")
|
|
1066
|
+
return changes
|
|
1067
|
+
|
|
1068
|
+
|
|
877
1069
|
def apply_many(root, provider, account_dirs, quiet=False, fail_open=False):
|
|
878
|
-
"""
|
|
1070
|
+
"""Adopt what these accounts hold that no section names, then reconcile each dir;
|
|
1071
|
+
returns the number of accounts that could not be."""
|
|
1072
|
+
try:
|
|
1073
|
+
# a launch must not wait long on a lock left by a killed holder: the next launch
|
|
1074
|
+
# or the limits pass adopts instead
|
|
1075
|
+
adopted = adopt(root, provider, account_dirs, lock_wait=2 if fail_open else 60)
|
|
1076
|
+
except Exception as exc: # noqa: BLE001 — adoption must never stop a reconcile
|
|
1077
|
+
adopted = []
|
|
1078
|
+
_warn(f"adopt: {type(exc).__name__}: {exc}")
|
|
1079
|
+
if adopted and not quiet:
|
|
1080
|
+
print(f"adopted {', '.join(adopted)}: every {provider} account gets "
|
|
1081
|
+
f"{'it' if len(adopted) == 1 else 'them'}")
|
|
1082
|
+
if adopted and len(account_dirs) < len(manifest_account_dirs(root)):
|
|
1083
|
+
# One account told the registry something new: the others need it as well (the
|
|
1084
|
+
# shims would bring it to each on its next launch; an idle one would wait).
|
|
1085
|
+
# (a dir being seeded is not in the manifest yet: it stays in the list)
|
|
1086
|
+
account_dirs = list(dict.fromkeys(list(account_dirs) + manifest_account_dirs(root)))
|
|
879
1087
|
failures = 0
|
|
880
1088
|
for account_dir in account_dirs:
|
|
881
1089
|
label = os.path.basename(account_dir.rstrip("/"))
|
|
@@ -1194,7 +1402,8 @@ def cmd_apply(args, leftovers):
|
|
|
1194
1402
|
if args.all or not args.account_dir:
|
|
1195
1403
|
rc = _apply_all(args, quiet=args.quiet)
|
|
1196
1404
|
return 0 if args.fail_open else rc
|
|
1197
|
-
failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet
|
|
1405
|
+
failures = apply_many(args.root, args.provider, args.account_dir, quiet=args.quiet,
|
|
1406
|
+
fail_open=args.fail_open)
|
|
1198
1407
|
return 0 if args.fail_open or not failures else 1
|
|
1199
1408
|
|
|
1200
1409
|
|
|
@@ -1251,8 +1460,12 @@ def cmd_import_local(args):
|
|
|
1251
1460
|
# it: an owner that stops shipping a server means it, and every account
|
|
1252
1461
|
# would otherwise keep the stale copy for ever.
|
|
1253
1462
|
dropped = [name for name in previous["mcpServers"] if name not in servers]
|
|
1463
|
+
# ...and stays retired until it publishes the name again: an account still holding
|
|
1464
|
+
# a copy must never have it adopted into the synced registry (adopt) once the next
|
|
1465
|
+
# import has forgotten the drop.
|
|
1466
|
+
kept = [name for name in previous["retired"] if name not in servers]
|
|
1254
1467
|
section = _normalize_section({"mcpServers": servers,
|
|
1255
|
-
"retired": sorted(set(retired) | set(dropped))})
|
|
1468
|
+
"retired": sorted(set(retired) | set(dropped) | set(kept))})
|
|
1256
1469
|
if previous == section and owner in overlay["owners"]:
|
|
1257
1470
|
changed = False
|
|
1258
1471
|
else:
|
|
@@ -1330,7 +1543,16 @@ def main(argv=None):
|
|
|
1330
1543
|
from codex_python import require_python311
|
|
1331
1544
|
require_python311(os.path.abspath(__file__))
|
|
1332
1545
|
if args.mode == "apply" and not _registry_present(args.root):
|
|
1333
|
-
|
|
1546
|
+
# Nothing registered: every account is already "reconciled" — unless one holds a
|
|
1547
|
+
# server to adopt (the first this pool ever had, added where the mirror never saw).
|
|
1548
|
+
dirs = args.account_dir if args.account_dir and not args.all \
|
|
1549
|
+
else manifest_account_dirs(args.root)
|
|
1550
|
+
try:
|
|
1551
|
+
if not adopt(args.root, args.provider, dirs, dry=True):
|
|
1552
|
+
return 0
|
|
1553
|
+
except Exception as exc: # noqa: BLE001 — the old no-op, never a failure
|
|
1554
|
+
_warn(f"adopt: {type(exc).__name__}: {exc}")
|
|
1555
|
+
return 0
|
|
1334
1556
|
if args.mode == "list":
|
|
1335
1557
|
return cmd_list(args)
|
|
1336
1558
|
if args.mode == "add":
|