claude-multiacc 2.0.57 → 2.0.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -3
- package/bin/claude +323 -84
- package/bin/claude-accounts +273 -42
- package/bin/codex +15 -0
- package/bin/codex-accounts +31 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +30 -3
- package/docs/AUTORESUME.md +275 -18
- package/docs/CODEX.md +1 -0
- package/docs/INSTALLATION.md +7 -1
- package/docs/VERIFICATION.md +4 -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/audit.py +88 -22
- package/lib/autoresume.py +1197 -82
- package/lib/autoresume_cross.py +3 -1
- package/lib/autoresume_handoff.py +82 -10
- package/lib/autoresume_probe.py +2 -2
- package/lib/claude_client_error.py +9 -4
- package/lib/codex_trust.py +263 -0
- package/lib/common.sh +131 -3
- package/lib/credential.py +5 -2
- package/lib/install_actions.sh +19 -0
- package/lib/keychain.py +180 -36
- package/lib/mcp_oauth.py +1748 -0
- package/lib/model_catalog.json +404 -0
- package/lib/model_catalog.py +201 -0
- package/lib/model_match.py +104 -0
- package/lib/report.py +8 -5
- package/lib/session_settings.py +123 -0
- package/package.json +2 -2
- 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/fake_codex_tui.py +25 -0
- package/tests/fake_security.sh +24 -2
- package/tests/fixtures/cross_provider_cli.py +111 -1
- package/tests/run-tests.sh +1028 -23
- package/tests/test_autoresume.py +215 -8
- package/tests/test_autoresume_catalog.py +226 -0
- package/tests/test_autoresume_catalog_cli.py +185 -0
- package/tests/test_autoresume_codex_records.py +16 -0
- package/tests/test_autoresume_codex_trust.py +247 -0
- package/tests/test_autoresume_cross_guards.py +28 -0
- package/tests/test_autoresume_cross_origin.py +70 -0
- package/tests/test_autoresume_cross_tmux.py +27 -2
- package/tests/test_autoresume_cross_tokens.py +32 -2
- package/tests/test_autoresume_handoff_argv.py +120 -16
- package/tests/test_autoresume_handoff_origins.py +67 -0
- package/tests/test_autoresume_memory.py +1141 -0
- package/tests/test_autoresume_memory_tmux.py +94 -0
- package/tests/test_autoresume_probe.py +2 -1
- package/tests/test_autoresume_relaunch_settings.py +317 -0
- package/tests/test_autoresume_settings.py +135 -0
- package/tests/test_autoresume_sidechain.py +1518 -0
- package/tests/test_autoresume_sidechain_tmux.py +79 -0
- package/tests/test_autoresume_trust_tmux.py +63 -0
- package/tests/test_claude_client_error.py +10 -7
- package/tests/test_claude_org_block.py +1 -0
- package/tests/test_claude_pool_prepare.py +4 -1
- package/tests/test_claude_reset.py +3 -0
- package/tests/test_keychain_nologin.py +340 -0
- package/tests/test_mcp_oauth.py +1414 -0
- package/tests/test_packaged_commands.py +41 -0
package/bin/claude-accounts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# claude-accounts — manage the claude-multiacc account pool.
|
|
3
3
|
# Subcommands: list status add import remove mint sync verify limits post-sync health
|
|
4
|
-
# expired relogin export-credential import-credential
|
|
4
|
+
# expired relogin export-credential import-credential model-catalog
|
|
5
5
|
set -u
|
|
6
6
|
# lib/audit.py is imported by several subcommands; keep the install tree free of
|
|
7
7
|
# __pycache__ (it may be root-owned, read-only, or an npm global prefix).
|
|
@@ -97,7 +97,14 @@ USAGE
|
|
|
97
97
|
Skips accounts fetched in the last 45s and honors 429/refresh backoff;
|
|
98
98
|
--force ignores all three.
|
|
99
99
|
claude-accounts health limits + full verify + shim-on-PATH probe; logs to health.log
|
|
100
|
-
claude-accounts self-update update this npm/git install; logs to update.log
|
|
100
|
+
claude-accounts self-update update this npm/git install (never to an older version); logs to update.log
|
|
101
|
+
claude-accounts model-catalog [refresh|show] [--if-stale] [--quiet]
|
|
102
|
+
the model/effort catalog auto-resume relaunches a session with. refresh (the default)
|
|
103
|
+
merges the catalog this release ships with the model caches the CLIs keep in their
|
|
104
|
+
account dirs (no request, no credential) into model-catalog.json in BOTH pool roots;
|
|
105
|
+
every install and self-update runs it, and each limits pass refreshes its own pool's
|
|
106
|
+
copy once it is an hour old. --if-stale leaves a younger copy alone; --quiet prints
|
|
107
|
+
nothing. show prints the merged catalog.
|
|
101
108
|
claude-accounts post-sync (server side) seed dirs, fix perms, quick verify
|
|
102
109
|
claude-accounts mcp add <name> [--provider claude|codex|both] [--scope user|project]
|
|
103
110
|
[--project PATH] [-e KEY=VAL]... [-H 'Header: v']...
|
|
@@ -123,6 +130,22 @@ USAGE
|
|
|
123
130
|
re-apply the registry now (repair drift)
|
|
124
131
|
claude-accounts mcp import-local --owner NAME (stdin JSON) machine-local overlay for
|
|
125
132
|
a runner daemon; applied here, never synced
|
|
133
|
+
claude-accounts mcp auth-status [--json] MCP OAuth logins shared across THIS machine's
|
|
134
|
+
accounts: per server the pool's login (expiry, source account, refresh state, why
|
|
135
|
+
it is not shared), per account whether its file / Keychain item holds it (fresh,
|
|
136
|
+
stale, own, stub, missing, absent). A server authenticated once in ANY account
|
|
137
|
+
works in every account: the shim merges the newest login into the picked account's
|
|
138
|
+
stores before exec (a setup-token account with no .credentials.json gets one holding
|
|
139
|
+
the MCP logins alone; a Keychain item is never created), and every limits pass
|
|
140
|
+
refreshes it ahead of expiry and shares it into all of them. Never prints a token.
|
|
141
|
+
claude-accounts mcp auth-sync [--force] harvest + refresh what is due + share now, one
|
|
142
|
+
line per server (--force: retry a refresh
|
|
143
|
+
that is backing off)
|
|
144
|
+
claude-accounts mcp auth-forget <server-name>
|
|
145
|
+
log that server out of EVERY account on this machine. A /mcp "Clear
|
|
146
|
+
authentication", `claude mcp login` or re-authentication in ANY one account
|
|
147
|
+
revokes the shared login at the server too, so the pool retires it everywhere;
|
|
148
|
+
one sign-in in any account then shares the new login
|
|
126
149
|
|
|
127
150
|
ENV
|
|
128
151
|
CLAUDE_ACCOUNTS_ROOT pool root, overriding ~/.claude-accounts — one isolated pool
|
|
@@ -136,6 +159,12 @@ ENV
|
|
|
136
159
|
CLAUDE_MULTIACC_DISABLE=1 bypass the shim entirely
|
|
137
160
|
CLAUDE_MULTIACC_AUTO_RESET=0 disable automatic limit-reset redemption
|
|
138
161
|
CLAUDE_MULTIACC_MCP=0 disable the MCP registry (no reconcile at seed/launch, no mirror)
|
|
162
|
+
CLAUDE_MULTIACC_MCP_OAUTH=0 stop sharing MCP OAuth logins (no merge at launch, no
|
|
163
|
+
sync in the limits pass); CLAUDE_MULTIACC_MCP_OAUTH_REFRESH=0
|
|
164
|
+
never refreshes them — and so shares none: one shared login
|
|
165
|
+
needs one refresher
|
|
166
|
+
CLAUDE_MULTIACC_MODEL_CATALOG=0 never refresh the claude pool's model-catalog.json
|
|
167
|
+
(MULTIACC_MODEL_CATALOG=0: neither pool's)
|
|
139
168
|
CLAUDE_MULTIACC_RESET_MIN_HORIZON a reset is held while every park lifts on
|
|
140
169
|
its own within this many seconds (default 3600)
|
|
141
170
|
EOF
|
|
@@ -432,6 +461,9 @@ for a in doc.get('accounts', []):
|
|
|
432
461
|
print(f" oauth creds : unreadable ({e})")
|
|
433
462
|
elif login['store'] == 'keychain':
|
|
434
463
|
print(f" oauth creds : in the macOS Keychain — {login['reason']}")
|
|
464
|
+
elif login['reason']:
|
|
465
|
+
# e.g. a .credentials.json the client wrote with MCP server state only
|
|
466
|
+
print(f" oauth creds : none on this machine — {login['reason']}")
|
|
435
467
|
else:
|
|
436
468
|
print(" oauth creds : none on this machine")
|
|
437
469
|
tpath = os.path.join(d, 'server.token')
|
|
@@ -706,6 +738,36 @@ add_cleanup_reserved() {
|
|
|
706
738
|
mutate_unlock 2>/dev/null || true
|
|
707
739
|
}
|
|
708
740
|
|
|
741
|
+
# Write the credentials document <src> INTO <dest>, the account's .credentials.json
|
|
742
|
+
# (0600, atomic): <src>'s top-level keys replace <dest>'s and every other key stays — the
|
|
743
|
+
# client keeps its MCP servers' OAuth state (mcpOAuth) in that same file. Fails, leaving
|
|
744
|
+
# <dest> as it was, when <src> is not a JSON object.
|
|
745
|
+
creds_merge_into() { # $1 = source document, $2 = destination .credentials.json
|
|
746
|
+
"$PYBIN" - "$1" "$2" <<'PYEOF'
|
|
747
|
+
import json, os, sys
|
|
748
|
+
src, dest = sys.argv[1], sys.argv[2]
|
|
749
|
+
try:
|
|
750
|
+
new = json.load(open(src))
|
|
751
|
+
except Exception:
|
|
752
|
+
sys.exit(1)
|
|
753
|
+
if not isinstance(new, dict):
|
|
754
|
+
sys.exit(1)
|
|
755
|
+
try:
|
|
756
|
+
cur = json.load(open(dest))
|
|
757
|
+
except Exception:
|
|
758
|
+
cur = None
|
|
759
|
+
doc = dict(cur) if isinstance(cur, dict) else {}
|
|
760
|
+
doc.update(new)
|
|
761
|
+
tmp = f'{dest}.tmp.{os.getpid()}'
|
|
762
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
763
|
+
with os.fdopen(fd, 'w') as f:
|
|
764
|
+
json.dump(doc, f)
|
|
765
|
+
f.flush()
|
|
766
|
+
os.fsync(f.fileno())
|
|
767
|
+
os.replace(tmp, dest)
|
|
768
|
+
PYEOF
|
|
769
|
+
}
|
|
770
|
+
|
|
709
771
|
cmd_import() {
|
|
710
772
|
require_manifest
|
|
711
773
|
local email="${1:-}"
|
|
@@ -735,6 +797,10 @@ cmd_import() {
|
|
|
735
797
|
# Validate ALL inputs before creating anything, so a bad token/creds path leaves no
|
|
736
798
|
# half-made account dir behind.
|
|
737
799
|
[ -n "$creds" ] && { [ -f "$creds" ] || die "credentials file not found: $creds"; }
|
|
800
|
+
# ...and a credentials file is a login only when it carries one: the client's MCP
|
|
801
|
+
# OAuth state alone would register an account with nothing to authenticate by.
|
|
802
|
+
[ -n "$creds" ] && { creds_file_has_login "$creds" \
|
|
803
|
+
|| die "credentials file holds no Claude login (no claudeAiOauth): $creds"; }
|
|
738
804
|
case "$mode" in copy|move|link) ;; *) die "mode must be copy|move|link" ;; esac
|
|
739
805
|
local tok=""
|
|
740
806
|
if [ -n "$token_file" ]; then
|
|
@@ -756,11 +822,19 @@ cmd_import() {
|
|
|
756
822
|
fi
|
|
757
823
|
fi
|
|
758
824
|
if [ -n "$creds" ]; then
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
825
|
+
if [ "$mode" != "link" ] && [ -s "$d/.credentials.json" ] && [ ! -L "$d/.credentials.json" ]; then
|
|
826
|
+
# The account's file may already hold its MCP servers' OAuth state: the imported
|
|
827
|
+
# login lands INTO it rather than signing every one of them out.
|
|
828
|
+
creds_merge_into "$creds" "$d/.credentials.json" \
|
|
829
|
+
|| die "could not merge $creds into $d/.credentials.json — nothing imported"
|
|
830
|
+
[ "$mode" = "move" ] && rm -f "$creds"
|
|
831
|
+
else
|
|
832
|
+
case "$mode" in
|
|
833
|
+
copy) cp "$creds" "$d/.credentials.json" ;;
|
|
834
|
+
move) mv "$creds" "$d/.credentials.json" ;;
|
|
835
|
+
link) ln -sf "$(canon_path "$creds")" "$d/.credentials.json" ;;
|
|
836
|
+
esac
|
|
837
|
+
fi
|
|
764
838
|
chmod 600 "$d/.credentials.json" 2>/dev/null || true
|
|
765
839
|
fi
|
|
766
840
|
if [ -n "$tok" ]; then
|
|
@@ -783,13 +857,14 @@ import json, os, re, sys
|
|
|
783
857
|
manifest, root = sys.argv[1], sys.argv[2]
|
|
784
858
|
sys.path = [sys.argv[3]] + [p for p in sys.path if p not in ('', '.')]
|
|
785
859
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
860
|
+
from audit import file_has_login # noqa: E402 (a login, not MCP state alone)
|
|
786
861
|
accts = [a for a in json.load(open(manifest)).get('accounts', [])
|
|
787
862
|
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', '')))]
|
|
788
863
|
def has_auth(aid):
|
|
789
864
|
d = os.path.join(root, aid)
|
|
790
865
|
c = os.path.join(d, '.credentials.json')
|
|
791
866
|
t = os.path.join(d, 'server.token')
|
|
792
|
-
if (
|
|
867
|
+
if file_has_login(c) or (os.path.isfile(t) and os.path.getsize(t) > 0):
|
|
793
868
|
return True
|
|
794
869
|
# A login the client moved into the macOS Keychain still counts — deduping must
|
|
795
870
|
# not throw away the one duplicate that actually holds the grant, even one filed
|
|
@@ -863,7 +938,7 @@ cmd_adopt() {
|
|
|
863
938
|
valid_acct_id "$id" || die "not a valid account id: $id"
|
|
864
939
|
account_ids | grep -qx "$id" || die "unknown account: $id (import it first)"
|
|
865
940
|
local d="$ACC_ROOT/$id" default="$HOME/.claude"
|
|
866
|
-
|
|
941
|
+
creds_file_has_login "$default/.credentials.json" || die "no default login at $default to adopt"
|
|
867
942
|
# Seed the config-dir state file inside ~/.claude (CLAUDE_CONFIG_DIR mode reads
|
|
868
943
|
# <dir>/.claude.json, while default mode uses ~/.claude.json at HOME level).
|
|
869
944
|
if [ ! -f "$default/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
|
@@ -883,7 +958,11 @@ PYEOF
|
|
|
883
958
|
return 0
|
|
884
959
|
fi
|
|
885
960
|
if [ -d "$d" ]; then
|
|
886
|
-
|
|
961
|
+
# ANY credential refuses, not only a login: the rm below destroys what the dir holds,
|
|
962
|
+
# and the client keeps its MCP servers' OAuth grants in .credentials.json even beside
|
|
963
|
+
# no login at all (2.1.303) — while a portable token is a credential in its own right.
|
|
964
|
+
{ [ -f "$d/.credentials.json" ] || [ -s "$d/server.token" ]; } \
|
|
965
|
+
&& die "$id already has its own credentials — refusing to replace with adopt"
|
|
887
966
|
rm -rf "${ACC_ROOT:?}/${id:?}"
|
|
888
967
|
fi
|
|
889
968
|
ln -s "$default" "$d"
|
|
@@ -1356,7 +1435,9 @@ cmd_login() {
|
|
|
1356
1435
|
# as the wrong account (died above) must leave every item as it was.
|
|
1357
1436
|
keychain_prune "$d"
|
|
1358
1437
|
clear_auth_markers "$d" keep-token-park
|
|
1359
|
-
|
|
1438
|
+
# Where the login landed: the file only when it carries one — beside a Keychain
|
|
1439
|
+
# login it may hold the client's MCP server state alone.
|
|
1440
|
+
if creds_file_has_login "$d/.credentials.json"; then
|
|
1360
1441
|
echo "$id login saved (.credentials.json, this machine, auto-refreshing)."
|
|
1361
1442
|
else
|
|
1362
1443
|
echo "$id login saved (macOS Keychain, this machine, auto-refreshing — sessions without keychain access, e.g. ssh, cannot use it)."
|
|
@@ -1583,6 +1664,27 @@ os.execv(sys.argv[1], [sys.argv[1], "limits-distribute-now"])
|
|
|
1583
1664
|
return 0
|
|
1584
1665
|
}
|
|
1585
1666
|
|
|
1667
|
+
# MCP OAuth logins (lib/mcp_oauth.py), once per pass: harvest every account's store,
|
|
1668
|
+
# refresh what is due (two hours ahead, or half a shorter lifetime) — the ONE refresher,
|
|
1669
|
+
# long before any client's own 5-minute window, because refresh tokens rotate — and share
|
|
1670
|
+
# the newest login into every account's existing stores. Its beat is what the shim's merge
|
|
1671
|
+
# shares behind, so a pool whose pass stops running stops spreading logins. Machine-local
|
|
1672
|
+
# (nothing here is pushed anywhere), so a replica runs it too. Detached the same way as
|
|
1673
|
+
# the telemetry push, so neither the pass nor a `--json` caller waits on a token
|
|
1674
|
+
# endpoint; the sync bounds itself and fails open.
|
|
1675
|
+
mcp_oauth_kick() {
|
|
1676
|
+
[ "${CLAUDE_MULTIACC_MCP_OAUTH:-1}" = "0" ] && return 0
|
|
1677
|
+
[ -f "$LIB_DIR/mcp_oauth.py" ] || return 0
|
|
1678
|
+
"$PYBIN" -c '
|
|
1679
|
+
import os, sys
|
|
1680
|
+
if os.fork():
|
|
1681
|
+
sys.exit(0)
|
|
1682
|
+
os.setsid()
|
|
1683
|
+
os.execv(sys.executable, [sys.executable] + sys.argv[1:])
|
|
1684
|
+
' "$LIB_DIR/mcp_oauth.py" sync --root "$ACC_ROOT" --quiet >/dev/null 2>&1 </dev/null &
|
|
1685
|
+
return 0
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1586
1688
|
token_identities_check() {
|
|
1587
1689
|
# A source may always use Keychain OAuth locally while its peers use setup tokens.
|
|
1588
1690
|
# Prove those tokens before distributing telemetry, even if nobody launched them here.
|
|
@@ -1639,7 +1741,8 @@ limits_distribute_now() {
|
|
|
1639
1741
|
done
|
|
1640
1742
|
# The MCP registry rides along: a server the shim mirrored from a stock `claude mcp
|
|
1641
1743
|
# add` reaches every peer within one limits cadence, with no explicit sync. The
|
|
1642
|
-
# machine-local overlay stays home (see sync_push_target).
|
|
1744
|
+
# machine-local overlay stays home (see sync_push_target), and so does mcp-oauth.json:
|
|
1745
|
+
# its MCP OAuth grants belong to this machine (lib/mcp_oauth.py).
|
|
1643
1746
|
[ -f "$ACC_ROOT/mcp-servers.json" ] && printf 'mcp-servers.json\n' >> "$list"
|
|
1644
1747
|
if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
|
|
1645
1748
|
|
|
@@ -1726,7 +1829,7 @@ cmd_limits() {
|
|
|
1726
1829
|
import hashlib, json, os, sys, time, urllib.request
|
|
1727
1830
|
sys.path = [sys.argv[6]] + [p for p in sys.path if p not in ('', '.')]
|
|
1728
1831
|
import keychain # noqa: E402 (macOS Keychain-held logins; a no-op elsewhere)
|
|
1729
|
-
from audit import creds_doc_state, org_block_in_force # noqa: E402 (one rule for "can this credential work")
|
|
1832
|
+
from audit import creds_doc_state, file_has_login, org_block_in_force # noqa: E402 (one rule for "can this credential work")
|
|
1730
1833
|
import claude_reset # noqa: E402 (automatic limit-reset redemption, the codex_reset twin)
|
|
1731
1834
|
|
|
1732
1835
|
root, threshold, quiet, url, force = sys.argv[1], int(sys.argv[2]), sys.argv[3] == '1', sys.argv[4], sys.argv[5] == '1'
|
|
@@ -1905,20 +2008,66 @@ def park_dead_grant(aid, d, slug, detail):
|
|
|
1905
2008
|
say(f'{aid}: {detail} — re-login needed (claude-accounts relogin {aid})')
|
|
1906
2009
|
|
|
1907
2010
|
|
|
2011
|
+
# The client's own write lock: proper-lockfile's DIRECTORY <dir>/.storage-write.lock, stale
|
|
2012
|
+
# after 15 s (lib/mcp_oauth.py ClientLock). Claude Code takes it around every write of its
|
|
2013
|
+
# credential document, file or Keychain item alike — re-read, one change, the whole
|
|
2014
|
+
# document back — and its write FAILS after about 7.5 s of retries on a held lock. So the
|
|
2015
|
+
# refresher never holds it across the token POST: a lock held when a refresh would start
|
|
2016
|
+
# skips that refresh (nothing has rotated yet; the next pass retries), and the
|
|
2017
|
+
# check-and-set and the write take it after the POST, waiting longer, because a rotated
|
|
2018
|
+
# grant must land: past the wait (or with no helper) it is written as before, without it.
|
|
2019
|
+
try:
|
|
2020
|
+
from mcp_oauth import CLIENT_LOCK, CLIENT_LOCK_STALE_S, ClientLock # noqa: E402
|
|
2021
|
+
except Exception: # noqa: BLE001 — fail open: refresh exactly as before, unlocked
|
|
2022
|
+
ClientLock = None
|
|
2023
|
+
CLIENT_LOCK_PROBE_S = 1.0 # a client's write takes milliseconds: held this long, it is busy
|
|
2024
|
+
CLIENT_LOCK_WAIT_S = 10.0
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def client_lock_busy(d):
|
|
2028
|
+
"""A live writer holds <d>'s client lock, and still does CLIENT_LOCK_PROBE_S later."""
|
|
2029
|
+
if ClientLock is None:
|
|
2030
|
+
return False
|
|
2031
|
+
path = os.path.join(d, CLIENT_LOCK)
|
|
2032
|
+
deadline = time.monotonic() + CLIENT_LOCK_PROBE_S
|
|
2033
|
+
while True:
|
|
2034
|
+
try:
|
|
2035
|
+
if time.time() - os.stat(path).st_mtime > CLIENT_LOCK_STALE_S:
|
|
2036
|
+
return False # its holder is gone; the write takes it over
|
|
2037
|
+
except OSError:
|
|
2038
|
+
return False # no lock: nobody is writing
|
|
2039
|
+
if time.monotonic() >= deadline:
|
|
2040
|
+
return True
|
|
2041
|
+
time.sleep(0.05)
|
|
2042
|
+
|
|
2043
|
+
|
|
1908
2044
|
class FileStore:
|
|
1909
|
-
"""The OAuth credential as `.credentials.json`: atomic 0600 writes.
|
|
2045
|
+
"""The OAuth credential as `.credentials.json`: atomic 0600 writes. The client keeps
|
|
2046
|
+
its MCP servers' OAuth state (mcpOAuth, mcpOAuthClientConfig) in this same file, so
|
|
2047
|
+
a write lands INTO the document as it is at that moment: the keys it is given replace
|
|
2048
|
+
theirs, every other key stays (the last good read stands in for a file caught
|
|
2049
|
+
mid-write). Replacing it would sign every MCP server of the account out."""
|
|
1910
2050
|
kind = 'file'
|
|
1911
2051
|
|
|
1912
2052
|
def __init__(self, path):
|
|
1913
2053
|
self.path = path
|
|
2054
|
+
self.base = None
|
|
1914
2055
|
|
|
1915
2056
|
def read(self):
|
|
1916
|
-
|
|
2057
|
+
doc = json.load(open(self.path))
|
|
2058
|
+
self.base = doc if isinstance(doc, dict) else None
|
|
2059
|
+
return doc
|
|
1917
2060
|
|
|
1918
2061
|
def write(self, doc):
|
|
2062
|
+
try:
|
|
2063
|
+
cur = json.load(open(self.path))
|
|
2064
|
+
except Exception:
|
|
2065
|
+
cur = self.base
|
|
2066
|
+
merged = dict(cur) if isinstance(cur, dict) else {}
|
|
2067
|
+
merged.update(doc)
|
|
1919
2068
|
fd = os.open(self.path + '.tmp', os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
1920
2069
|
with os.fdopen(fd, 'w') as f:
|
|
1921
|
-
json.dump(
|
|
2070
|
+
json.dump(merged, f)
|
|
1922
2071
|
f.flush()
|
|
1923
2072
|
os.fsync(f.fileno())
|
|
1924
2073
|
os.replace(self.path + '.tmp', self.path)
|
|
@@ -1931,7 +2080,8 @@ class KeychainStore:
|
|
|
1931
2080
|
back under the canonical name, which also drops the siblings holding the grant the
|
|
1932
2081
|
refresh started from — NEVER copied out to a file: a file beside a Keychain item is
|
|
1933
2082
|
a second copy of a ROTATING refresh grant, and the client reads the Keychain first,
|
|
1934
|
-
so the two would drift apart and strand one.
|
|
2083
|
+
so the two would drift apart and strand one. keychain.write lands INTO the item, so
|
|
2084
|
+
its MCP state stays as it is."""
|
|
1935
2085
|
kind = 'keychain'
|
|
1936
2086
|
|
|
1937
2087
|
def __init__(self, d, probe):
|
|
@@ -1955,19 +2105,35 @@ class KeychainStore:
|
|
|
1955
2105
|
def oauth_store(d):
|
|
1956
2106
|
"""Where <d>'s OAuth login lives for THIS process: a FileStore, a KeychainStore,
|
|
1957
2107
|
the string 'locked' (Keychain item exists but this session cannot open it), or
|
|
1958
|
-
None. The file wins when
|
|
2108
|
+
None. The file wins when it holds a login — same rule as lib/audit.oauth_login; one
|
|
2109
|
+
holding only MCP state is no login store, and the Keychain is asked instead.
|
|
1959
2110
|
|
|
1960
2111
|
A Keychain login found only under a name its client no longer reads (a USER-less
|
|
1961
2112
|
sign-in's "unknown", lib/keychain.py) is moved under the canonical name here, once:
|
|
1962
2113
|
until then the client, the shim and the audit all see no login."""
|
|
1963
2114
|
cpath = os.path.join(d, '.credentials.json')
|
|
1964
|
-
if
|
|
2115
|
+
if file_has_login(cpath):
|
|
1965
2116
|
return FileStore(cpath)
|
|
1966
2117
|
p = keychain.probe(d, legacy=True)
|
|
1967
2118
|
if p['state'] == 'present':
|
|
1968
2119
|
store = KeychainStore(d, p)
|
|
1969
2120
|
if p['account'] != p['canonical']:
|
|
1970
|
-
|
|
2121
|
+
# INTO the item the client reads: MCP state it already holds stays as it is;
|
|
2122
|
+
# the stranded item's own comes along only where that item has none. Under
|
|
2123
|
+
# the client's lock, like the refresher's write (a held one is waited on, then
|
|
2124
|
+
# the move goes ahead: until it lands no client can see this login at all).
|
|
2125
|
+
lock = ClientLock(d, CLIENT_LOCK_WAIT_S) if ClientLock is not None else None
|
|
2126
|
+
try:
|
|
2127
|
+
held = lock is not None and lock.acquire()
|
|
2128
|
+
except Exception: # noqa: BLE001
|
|
2129
|
+
held = False
|
|
2130
|
+
try:
|
|
2131
|
+
here = keychain.probe(d).get('doc') or {}
|
|
2132
|
+
store.moved = keychain.write(d, {k: v for k, v in p['doc'].items()
|
|
2133
|
+
if k == 'claudeAiOauth' or k not in here})
|
|
2134
|
+
finally:
|
|
2135
|
+
if held:
|
|
2136
|
+
lock.release()
|
|
1971
2137
|
return store
|
|
1972
2138
|
if p['state'] == 'locked':
|
|
1973
2139
|
return 'locked'
|
|
@@ -1984,10 +2150,11 @@ def refresh_oauth(aid, d, store):
|
|
|
1984
2150
|
spath = os.path.join(d, '.oauth-refresh.json')
|
|
1985
2151
|
try:
|
|
1986
2152
|
doc = store.read()
|
|
1987
|
-
o = doc.get('claudeAiOauth',
|
|
2153
|
+
o = doc.get('claudeAiOauth') if isinstance(doc, dict) else None
|
|
1988
2154
|
# Present-but-null/non-object claudeAiOauth (interrupted or reset credential
|
|
1989
|
-
# write) must degrade THIS account only, like every other malformed input
|
|
1990
|
-
|
|
2155
|
+
# write) must degrade THIS account only, like every other malformed input; and a
|
|
2156
|
+
# document with no login in it at all (MCP state alone) has nothing to refresh.
|
|
2157
|
+
if not isinstance(o, dict):
|
|
1991
2158
|
return None
|
|
1992
2159
|
except Exception:
|
|
1993
2160
|
return None
|
|
@@ -2026,6 +2193,12 @@ def refresh_oauth(aid, d, store):
|
|
|
2026
2193
|
pass
|
|
2027
2194
|
say(f'{aid}: oauth refresh failed ({why}); backing off {wait}s; limits left as-is')
|
|
2028
2195
|
|
|
2196
|
+
if client_lock_busy(d):
|
|
2197
|
+
# The client is writing this credential right now (it may be its own refresh of
|
|
2198
|
+
# the same grant): no request is sent, so nothing rotates, and no backoff is noted.
|
|
2199
|
+
say(f'{aid}: oauth refresh skipped — the client holds its credential lock '
|
|
2200
|
+
f'({CLIENT_LOCK}); the next pass retries')
|
|
2201
|
+
return None
|
|
2029
2202
|
started_with = o['refreshToken']
|
|
2030
2203
|
body = json.dumps({'grant_type': 'refresh_token',
|
|
2031
2204
|
'refresh_token': o['refreshToken'],
|
|
@@ -2086,25 +2259,39 @@ def refresh_oauth(aid, d, store):
|
|
|
2086
2259
|
o['expiresAt'] = int((now + 3600) * 1000)
|
|
2087
2260
|
if data.get('refresh_token_expires_in'):
|
|
2088
2261
|
o['refreshTokenExpiresAt'] = int((now + float(data['refresh_token_expires_in'])) * 1000)
|
|
2089
|
-
doc['claudeAiOauth'] = o
|
|
2090
2262
|
# CHECK-AND-SET. The grant rotates, and a live claude session refreshes the same
|
|
2091
2263
|
# file. REFRESH_MIN_EXPIRED makes that unlikely, not impossible — and losing the
|
|
2092
2264
|
# race by overwriting means the session's newer credential is destroyed. If the
|
|
2093
2265
|
# on-disk refresh token is no longer the one this grant was issued against, the
|
|
2094
|
-
# other writer won: keep its result, discard ours.
|
|
2266
|
+
# other writer won: keep its result, discard ours. Both under the client's own lock,
|
|
2267
|
+
# so no client write lands between the check and the write.
|
|
2268
|
+
lock = ClientLock(d, CLIENT_LOCK_WAIT_S) if ClientLock is not None else None
|
|
2095
2269
|
try:
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
pass
|
|
2270
|
+
held = lock is not None and lock.acquire()
|
|
2271
|
+
except Exception: # noqa: BLE001 — the rotated grant lands either way
|
|
2272
|
+
held = False
|
|
2273
|
+
if lock is not None and not held and os.path.isdir(lock.path):
|
|
2274
|
+
say(f'{aid}: the client kept its credential lock ({CLIENT_LOCK}) past '
|
|
2275
|
+
f'{int(CLIENT_LOCK_WAIT_S)}s — writing the rotated login without it')
|
|
2103
2276
|
try:
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2277
|
+
try:
|
|
2278
|
+
disk = store.read().get('claudeAiOauth', {})
|
|
2279
|
+
if isinstance(disk, dict) and disk.get('refreshToken') != started_with:
|
|
2280
|
+
say(f'{aid}: credential was refreshed by something else mid-flight — '
|
|
2281
|
+
f'keeping the newer one in the {store.kind}')
|
|
2282
|
+
return None
|
|
2283
|
+
except Exception:
|
|
2284
|
+
pass
|
|
2285
|
+
try:
|
|
2286
|
+
# Only the login is ours: both stores write it INTO the document as it is now,
|
|
2287
|
+
# so the MCP state the client saved meanwhile (mcpOAuth) is never rolled back.
|
|
2288
|
+
store.write({'claudeAiOauth': o})
|
|
2289
|
+
except Exception as e:
|
|
2290
|
+
say(f'{aid}: token refreshed but credentials NOT persisted ({e}) — re-login may be needed')
|
|
2291
|
+
return None
|
|
2292
|
+
finally:
|
|
2293
|
+
if held:
|
|
2294
|
+
lock.release()
|
|
2108
2295
|
try:
|
|
2109
2296
|
os.remove(spath)
|
|
2110
2297
|
except OSError:
|
|
@@ -2779,6 +2966,10 @@ PYEOF
|
|
|
2779
2966
|
# Whoever just refreshed is the only machine that CAN — pass it on before the
|
|
2780
2967
|
# report, so a blind peer stops picking drained accounts within one cycle.
|
|
2781
2968
|
limits_distribute
|
|
2969
|
+
# This machine's MCP OAuth logins: harvested, refreshed when due, shared (detached).
|
|
2970
|
+
mcp_oauth_kick
|
|
2971
|
+
# This pool's model catalog, once its copy is an hour old (lib/common.sh).
|
|
2972
|
+
model_catalog_backstop
|
|
2782
2973
|
# The refresher fails open per account; a NON-zero status means the pass itself
|
|
2783
2974
|
# broke (unreadable manifest, dead python). Report the state anyway — stale data
|
|
2784
2975
|
# beats silence — but hand the caller the failure, exactly as before --json existed.
|
|
@@ -2802,7 +2993,7 @@ import hashlib, json, os, re, subprocess, sys, time
|
|
|
2802
2993
|
root, quick, real = sys.argv[1], sys.argv[2] == '1', sys.argv[3]
|
|
2803
2994
|
sys.path = [sys.argv[4]] + [p for p in sys.path if p not in ('', '.')]
|
|
2804
2995
|
from audit import (audit_account, creds_state, creds_doc_state, # noqa: E402 (shared with the shim's rule)
|
|
2805
|
-
marker_slug, org_block_in_force)
|
|
2996
|
+
file_has_login, marker_slug, org_block_in_force)
|
|
2806
2997
|
import keychain # noqa: E402
|
|
2807
2998
|
from claude_token_identity import check_identity # noqa: E402
|
|
2808
2999
|
from claude_reset import cli_version # noqa: E402
|
|
@@ -2933,7 +3124,10 @@ for acct in manifest.get('accounts', []):
|
|
|
2933
3124
|
d = os.path.join(root, aid)
|
|
2934
3125
|
cpath = os.path.join(d, '.credentials.json')
|
|
2935
3126
|
tpath = os.path.join(d, 'server.token')
|
|
2936
|
-
|
|
3127
|
+
# A login, not merely a file: one holding the client's MCP state alone is no
|
|
3128
|
+
# credential to test — testing "it" ran the client with nothing to authenticate by,
|
|
3129
|
+
# and its "Not logged in" parked the account (the shim's file_login, 2026-10-19).
|
|
3130
|
+
has_creds = file_has_login(cpath)
|
|
2937
3131
|
has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
|
|
2938
3132
|
cred_doc = None
|
|
2939
3133
|
cred_state = None
|
|
@@ -3087,7 +3281,9 @@ PYEOF
|
|
|
3087
3281
|
# A target can be the Linux server (token-authenticated) or a peer Mac (which has
|
|
3088
3282
|
# its own machine-local OAuth logins) — the pushed material is valid on both:
|
|
3089
3283
|
# manifest, portable server.token files, seeds, and advisory limit state.
|
|
3090
|
-
# Credentials (.credentials.json) are NEVER pushed anywhere
|
|
3284
|
+
# Credentials (.credentials.json) are NEVER pushed anywhere — nor is the pool's
|
|
3285
|
+
# mcp-oauth.json, which holds this machine's MCP OAuth grants (lib/mcp_oauth.py): a
|
|
3286
|
+
# rotating refresh token used from two machines strands one of them.
|
|
3091
3287
|
sync_push_target() {
|
|
3092
3288
|
local server="$1" sroot="$2" srepo="$3"
|
|
3093
3289
|
slog "push -> $server:$sroot"
|
|
@@ -3317,8 +3513,9 @@ cmd_mcp() {
|
|
|
3317
3513
|
local args=()
|
|
3318
3514
|
case "$sub" in
|
|
3319
3515
|
add|add-json|remove|list|apply|import-local) shift ;;
|
|
3516
|
+
auth-status|auth-sync|auth-forget) shift; cmd_mcp_auth "$sub" "$@"; return $? ;;
|
|
3320
3517
|
"") usage; return 1 ;;
|
|
3321
|
-
*) die "unknown mcp subcommand: $sub (add, add-json, remove, list, apply, import-local)" ;;
|
|
3518
|
+
*) die "unknown mcp subcommand: $sub (add, add-json, remove, list, apply, import-local, auth-status, auth-sync, auth-forget)" ;;
|
|
3322
3519
|
esac
|
|
3323
3520
|
[ -f "$LIB_DIR/mcp_registry.py" ] || die "lib/mcp_registry.py is missing from this install"
|
|
3324
3521
|
case "$sub" in
|
|
@@ -3389,6 +3586,24 @@ cmd_mcp() {
|
|
|
3389
3586
|
return "$rc"
|
|
3390
3587
|
}
|
|
3391
3588
|
|
|
3589
|
+
# MCP OAuth logins shared across THIS machine's accounts (lib/mcp_oauth.py) — the claude
|
|
3590
|
+
# pool alone, and never the sibling hop above: the store is <pool>/mcp-oauth.json of the
|
|
3591
|
+
# pool this CLI was pinned to, and nothing about it is synced.
|
|
3592
|
+
cmd_mcp_auth() { # $1 = auth-status|auth-sync|auth-forget, then its options
|
|
3593
|
+
local sub="$1"
|
|
3594
|
+
shift
|
|
3595
|
+
[ -f "$LIB_DIR/mcp_oauth.py" ] || die "lib/mcp_oauth.py is missing from this install"
|
|
3596
|
+
case "$sub" in
|
|
3597
|
+
auth-status)
|
|
3598
|
+
"$PYBIN" "$LIB_DIR/mcp_oauth.py" status --root "$ACC_ROOT" "$@" ;;
|
|
3599
|
+
auth-sync)
|
|
3600
|
+
"$PYBIN" "$LIB_DIR/mcp_oauth.py" sync --root "$ACC_ROOT" "$@" ;;
|
|
3601
|
+
auth-forget)
|
|
3602
|
+
[ $# -ge 1 ] || die "usage: $PROVIDER_CLI mcp auth-forget <server-name>"
|
|
3603
|
+
"$PYBIN" "$LIB_DIR/mcp_oauth.py" forget --root "$ACC_ROOT" "$@" ;;
|
|
3604
|
+
esac
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3392
3607
|
cmd_post_sync() {
|
|
3393
3608
|
require_manifest
|
|
3394
3609
|
local_pool_fixup
|
|
@@ -3442,7 +3657,21 @@ cmd_self_update() {
|
|
|
3442
3657
|
ulog "self-update: already latest ($cur)"
|
|
3443
3658
|
return 0
|
|
3444
3659
|
fi
|
|
3445
|
-
|
|
3660
|
+
# Only ever UP, and only to a version known to be newer. A copy installed by hand ahead
|
|
3661
|
+
# of the registry (this Mac's builds, while npm publishing is down) must survive the
|
|
3662
|
+
# app-robot loop that calls this every 10 minutes: installing `latest` over it swapped
|
|
3663
|
+
# in a build that misreads state the newer one had already written (MCP-only
|
|
3664
|
+
# credential files: every setup-token account "Not logged in"). An unreadable registry
|
|
3665
|
+
# answer is no licence to install blind either.
|
|
3666
|
+
case "$lat" in [0-9]*.*[0-9]) ;; *)
|
|
3667
|
+
ulog "self-update: could not read the registry's version (npm view); not updating"
|
|
3668
|
+
return 1 ;;
|
|
3669
|
+
esac
|
|
3670
|
+
if ver_newer "$cur" "$lat"; then
|
|
3671
|
+
ulog "self-update: this install ($cur) is newer than the registry's $lat; not downgrading"
|
|
3672
|
+
return 0
|
|
3673
|
+
fi
|
|
3674
|
+
ulog "self-update: npm $cur -> $lat (prefix $prefix, npm $npm_bin)"
|
|
3446
3675
|
if PATH="$npm_path" "$npm_bin" install -g --prefix "$prefix" claude-multiacc@latest \
|
|
3447
3676
|
>>"$ACC_ROOT/update.log" 2>&1; then
|
|
3448
3677
|
# Verify by re-reading the file on disk. npm reporting success says nothing
|
|
@@ -3453,6 +3682,7 @@ cmd_self_update() {
|
|
|
3453
3682
|
return 1
|
|
3454
3683
|
fi
|
|
3455
3684
|
ulog "self-update: npm update ok ($after)"
|
|
3685
|
+
model_catalog_after_update "$quiet"
|
|
3456
3686
|
else
|
|
3457
3687
|
ulog "self-update: npm update FAILED (see update.log)"
|
|
3458
3688
|
return 1
|
|
@@ -3504,7 +3734,7 @@ $shim_out"
|
|
|
3504
3734
|
# distribution for as long as the arg loops rejected the flag. An UNKNOWN verb must still
|
|
3505
3735
|
# FAIL, or the probe stops meaning what it says — so this list must hold exactly the verbs
|
|
3506
3736
|
# the dispatcher below implements, and a test pins that both ways.
|
|
3507
|
-
_KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update mcp"
|
|
3737
|
+
_KNOWN_VERBS="list status add import export-credential export-cred import-credential import-cred adopt dedupe remove mint login expired relogin re-login sync verify limits post-sync health self-update mcp model-catalog"
|
|
3508
3738
|
case " $_KNOWN_VERBS " in
|
|
3509
3739
|
*" ${1:-help} "*)
|
|
3510
3740
|
# ...but never past a literal `--`: from there on the words belong to an MCP
|
|
@@ -3537,6 +3767,7 @@ case "${1:-help}" in
|
|
|
3537
3767
|
health) shift; cmd_health "$@" ;;
|
|
3538
3768
|
self-update) shift; cmd_self_update "$@" ;;
|
|
3539
3769
|
mcp) shift; cmd_mcp "$@" ;;
|
|
3770
|
+
model-catalog) shift; cmd_model_catalog "$@" ;;
|
|
3540
3771
|
help|--help|-h) usage ;;
|
|
3541
3772
|
*) usage; exit 1 ;;
|
|
3542
3773
|
esac
|
package/bin/codex
CHANGED
|
@@ -766,6 +766,7 @@ AR_AVOID_CLAUDE="" AR_AVOID_CODEX="" AR_POOL_CLAUDE="" AR_POOL_CODEX=""
|
|
|
766
766
|
AR_ARGV=()
|
|
767
767
|
AR_ORIG_ARGV=()
|
|
768
768
|
AR_NO_DAEMON=0
|
|
769
|
+
AR_TRUST=0 # only a consumed automatic relaunch trusts its cwd in the new account
|
|
769
770
|
AR_ROLLOUT_CURSOR=""
|
|
770
771
|
AR_TIER="" # which cut produced the pick: eligible | soft | hard (probe answer)
|
|
771
772
|
AR_R_SID="" # the session a relaunch file named, for the dead-token hint
|
|
@@ -987,11 +988,14 @@ ar_relaunch() { # $1 token
|
|
|
987
988
|
# Field 2 is the word `autoresume`, never an account id: lib/report.py and the
|
|
988
989
|
# *-accounts tools read an acct-NN in field 2 as a pick.
|
|
989
990
|
sel_log "autoresume relaunch chain=${AR_CHAIN:--} from=${acct:--} class=${cls:--} depth=$AR_DEPTH"
|
|
991
|
+
AR_TRUST=1
|
|
990
992
|
return 0
|
|
991
993
|
}
|
|
992
994
|
|
|
993
995
|
# The argv shapes a relaunch can rebuild as `resume <kept opts> <sid> <prompt>` — anything
|
|
994
996
|
# else (exec/e, -p profile, -c overrides, any subcommand, an unknown flag) gets no watcher.
|
|
997
|
+
# The one -c a relaunch carries itself is let through: `-c model_reasoning_effort=<level>`,
|
|
998
|
+
# beside `-m`, pins the session's own effort (lib/autoresume.py codex_argv_parse mirrors it).
|
|
995
999
|
# A positional must contain a space to count as a prompt: a single word may be a
|
|
996
1000
|
# subcommand.
|
|
997
1001
|
ar_argv_ok() { # the argv codex is about to get
|
|
@@ -1002,6 +1006,8 @@ ar_argv_ok() { # the argv codex is about to get
|
|
|
1002
1006
|
sandbox:read-only) sandbox=1 ;;
|
|
1003
1007
|
approval:on-request) approval=1 ;;
|
|
1004
1008
|
model:*) case "$a" in ''|-*) return 1 ;; esac ;;
|
|
1009
|
+
effort:model_reasoning_effort=?*)
|
|
1010
|
+
case "${a#model_reasoning_effort=}" in *[!$AR_LOWER]*) return 1 ;; esac ;;
|
|
1005
1011
|
*) return 1 ;;
|
|
1006
1012
|
esac
|
|
1007
1013
|
want=""
|
|
@@ -1013,6 +1019,7 @@ ar_argv_ok() { # the argv codex is about to get
|
|
|
1013
1019
|
--sandbox) [ "$sandbox" = 0 ] || return 1; want=sandbox ;;
|
|
1014
1020
|
--ask-for-approval) [ "$approval" = 0 ] || return 1; want=approval ;;
|
|
1015
1021
|
-m|--model) want=model ;;
|
|
1022
|
+
-c) want=effort ;;
|
|
1016
1023
|
--model=?*) ;;
|
|
1017
1024
|
--last)
|
|
1018
1025
|
[ "$resume" = 1 ] && [ "$sid" = 0 ] || return 1
|
|
@@ -1554,6 +1561,14 @@ fi
|
|
|
1554
1561
|
share_state_index "$pick"
|
|
1555
1562
|
if [ "$wants_retry" = "0" ]; then
|
|
1556
1563
|
export CODEX_HOME="$pick"
|
|
1564
|
+
# Folder access is account-local even when the conversation and cwd are unchanged.
|
|
1565
|
+
# Persist the relaunch's trust before Codex starts, without sending keys into its TUI.
|
|
1566
|
+
if [ "$AR_TRUST" = "1" ]; then
|
|
1567
|
+
if ! ar_find_python || ! "$AR_PY" -I "$SELF_DIR/../lib/codex_trust.py" \
|
|
1568
|
+
--acct-dir "$pick" --cwd "$PWD" </dev/null >/dev/null 2>&1; then
|
|
1569
|
+
sel_log "autoresume trust-failed chain=${AR_CHAIN:--} acct=${pick##*/}"
|
|
1570
|
+
fi
|
|
1571
|
+
fi
|
|
1557
1572
|
# Auto-resume: a gated, detached watcher beside this exec — the exec itself (same pid,
|
|
1558
1573
|
# same environment minus the auto-resume variables). Supported clients own their
|
|
1559
1574
|
# task through --no-daemon, including the continuation passed on resume.
|