claude-multiacc 2.0.58 → 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 +125 -4
- package/bin/claude +389 -94
- package/bin/claude-accounts +276 -42
- package/bin/codex +69 -5
- package/bin/codex-accounts +33 -4
- package/docs/ACCOUNT_OPERATIONS.md +62 -2
- package/docs/AUTORESUME-HANDOFF.md +28 -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 +81 -9
- 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 +146 -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/mcp_registry.py +230 -8
- 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 +109 -0
- package/tests/run-tests.sh +1171 -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_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 +14 -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_mcp_registry.py +234 -3
- package/tests/test_packaged_commands.py +41 -0
package/lib/autoresume_cross.py
CHANGED
|
@@ -85,9 +85,11 @@ def plan_fallback(watcher, verdict, source_avoid):
|
|
|
85
85
|
if shared_source(watcher):
|
|
86
86
|
watcher.hold(verdict, reason='shared-source')
|
|
87
87
|
return None
|
|
88
|
+
# Both pool roots feed the model catalog, so the target argv (probed below exactly as it will
|
|
89
|
+
# launch) pins the same-level model: a Claude target's model-scoped buckets judge that model.
|
|
88
90
|
handoff = build_handoff(watcher.provider, target, watcher.argv, watcher.sid, watcher.transcript,
|
|
89
91
|
watcher.session_cwd(), inode=watcher.tail.ino if watcher.tail else None,
|
|
90
|
-
prompt_override=watcher.cfg.prompt)
|
|
92
|
+
prompt_override=watcher.cfg.prompt, roots=roots)
|
|
91
93
|
if not handoff:
|
|
92
94
|
watcher.hold(verdict, reason='handoff-history-or-permissions')
|
|
93
95
|
return None
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
The watcher already knows the source conversation; this module never searches for a
|
|
4
4
|
replacement by directory or recency. It returns launch data without mutating either pool.
|
|
5
|
+
The target starts on the model and effort of the same level as the source session, read
|
|
6
|
+
from that validated history up to its snapshot boundary and matched through the catalog;
|
|
7
|
+
anything unknown leaves the target CLI on its own defaults.
|
|
5
8
|
"""
|
|
6
9
|
|
|
7
10
|
import json
|
|
@@ -14,6 +17,10 @@ PROVIDERS = ("claude", "codex")
|
|
|
14
17
|
SID_RE = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
|
15
18
|
MAX_SOURCE_BYTES = 4 << 20
|
|
16
19
|
PROMPT_PREFIX = "(claude-multiacc auto-resume)"
|
|
20
|
+
# Only argv-safe values leave a history: never option-shaped, no whitespace or control text,
|
|
21
|
+
# and an effort is a plain lowercase word -- what bin/codex accepts after model_reasoning_effort=.
|
|
22
|
+
MODEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/@+\[\]-]{0,199}$")
|
|
23
|
+
EFFORT_RE = re.compile(r"^[a-z]{1,32}$")
|
|
17
24
|
|
|
18
25
|
|
|
19
26
|
def _read_source(path, inode):
|
|
@@ -113,7 +120,7 @@ def validate_source(provider, sid, path, cwd, inode=None):
|
|
|
113
120
|
|
|
114
121
|
|
|
115
122
|
def _permission_argv(source, argv):
|
|
116
|
-
"""Map explicit permission intent, never
|
|
123
|
+
"""Map explicit permission intent, never the source's model flags or resume flags."""
|
|
117
124
|
# Import lazily: autoresume imports this helper while initializing its own parsers.
|
|
118
125
|
from autoresume import claude_argv_parse, codex_argv_parse
|
|
119
126
|
|
|
@@ -139,7 +146,57 @@ def _permission_argv(source, argv):
|
|
|
139
146
|
return ["--sandbox", "read-only", "--ask-for-approval", "on-request"]
|
|
140
147
|
|
|
141
148
|
|
|
142
|
-
def
|
|
149
|
+
def _no_settings():
|
|
150
|
+
return {"source": {"model": None, "effort": None}, "target": {"model": None, "effort": None}, "flags": []}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _clean(values):
|
|
154
|
+
model, effort = (values or {}).get("model"), (values or {}).get("effort")
|
|
155
|
+
return {"model": model if isinstance(model, str) and MODEL_RE.fullmatch(model) else None,
|
|
156
|
+
"effort": effort if isinstance(effort, str) and EFFORT_RE.fullmatch(effort) else None}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def mapped_settings(history, target, roots=None):
|
|
160
|
+
"""``{source, target, flags}``: the source session's model/effort as its validated history
|
|
161
|
+
recorded them up to the snapshot boundary, the same-level target match, and the target argv
|
|
162
|
+
that pins it. ``roots`` ({provider: pool root}) feeds the catalog. Fail-open: an unreadable
|
|
163
|
+
history, an unknown model or any catalog error yields no flags, never a failed handoff."""
|
|
164
|
+
result = _no_settings()
|
|
165
|
+
try:
|
|
166
|
+
from session_settings import session_settings
|
|
167
|
+
source = _clean(session_settings(history["provider"], history["path"], upto=history["offset"]))
|
|
168
|
+
result["source"] = source
|
|
169
|
+
if source["model"] is None:
|
|
170
|
+
return result
|
|
171
|
+
from model_catalog import load
|
|
172
|
+
from model_match import resume_settings, settings_flags
|
|
173
|
+
catalog = load(roots)
|
|
174
|
+
target_settings = _clean(resume_settings(history["provider"], source, target, catalog))
|
|
175
|
+
# An effort name the target vocabulary does not know is omitted, not passed through.
|
|
176
|
+
if target_settings["effort"] not in ((catalog.get(target) or {}).get("efforts") or ()):
|
|
177
|
+
target_settings["effort"] = None
|
|
178
|
+
flags = settings_flags(target, target_settings["model"], target_settings["effort"])
|
|
179
|
+
if isinstance(flags, list) and all(isinstance(arg, str) for arg in flags):
|
|
180
|
+
result.update(target=target_settings, flags=list(flags))
|
|
181
|
+
except Exception: # noqa: BLE001 -- fail open: the target keeps its own model defaults
|
|
182
|
+
return _no_settings()
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _settings_sentence(target, settings):
|
|
187
|
+
"""Name the source model/effort and the matched target ones, only when the argv pins a model."""
|
|
188
|
+
source, matched = settings.get("source") or {}, settings.get("target") or {}
|
|
189
|
+
if not (source.get("model") and matched.get("model") and settings.get("flags")):
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
def named(values):
|
|
193
|
+
effort = values.get("effort")
|
|
194
|
+
return values["model"] + " (" + ("effort " + effort if effort else "default effort") + ")"
|
|
195
|
+
return (" Source model: " + named(source) + ". This conversation runs the matching " + target + " model "
|
|
196
|
+
+ named(matched) + ".")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def handoff_prompt(history, target, prompt_override=None, settings=None):
|
|
143
200
|
"""Mandatory identifiers and history instructions remain present with any override."""
|
|
144
201
|
source = history["provider"]
|
|
145
202
|
text = (
|
|
@@ -157,6 +214,8 @@ def handoff_prompt(history, target, prompt_override=None):
|
|
|
157
214
|
"has not sent a new message. Background work may have stopped or may still be running. Inspect "
|
|
158
215
|
"its state before relying on it or restarting it, and do not duplicate active or completed work."
|
|
159
216
|
)
|
|
217
|
+
if settings:
|
|
218
|
+
text += _settings_sentence(target, settings)
|
|
160
219
|
if isinstance(prompt_override, str) and prompt_override.strip() and not any(
|
|
161
220
|
character in prompt_override for character in "\0\n\r"):
|
|
162
221
|
text += " Additional continuation instruction: " + prompt_override.replace(
|
|
@@ -164,8 +223,8 @@ def handoff_prompt(history, target, prompt_override=None):
|
|
|
164
223
|
return text
|
|
165
224
|
|
|
166
225
|
|
|
167
|
-
def
|
|
168
|
-
"""
|
|
226
|
+
def _handoff(source_provider, target_provider, original_argv, history, prompt_override, roots):
|
|
227
|
+
"""(target argv, mapped settings), or None for unsupported source options or permission intent."""
|
|
169
228
|
if source_provider not in PROVIDERS or target_provider not in PROVIDERS or source_provider == target_provider:
|
|
170
229
|
return None
|
|
171
230
|
if not isinstance(history, dict) or history.get("provider") != source_provider:
|
|
@@ -179,13 +238,26 @@ def handoff_argv(source_provider, target_provider, original_argv, history, promp
|
|
|
179
238
|
if not isinstance(original_argv, (list, tuple)) or not all(isinstance(arg, str) for arg in original_argv):
|
|
180
239
|
return None
|
|
181
240
|
options = _permission_argv(source_provider, original_argv)
|
|
182
|
-
|
|
241
|
+
if options is None:
|
|
242
|
+
return None
|
|
243
|
+
settings = mapped_settings(history, target_provider, roots)
|
|
244
|
+
prompt = handoff_prompt(history, target_provider, prompt_override, settings)
|
|
245
|
+
# Permission options first, then the model pins; the prompt stays the last argument.
|
|
246
|
+
return options + settings["flags"] + [prompt], settings
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def handoff_argv(source_provider, target_provider, original_argv, history, prompt_override=None, roots=None):
|
|
250
|
+
"""Fresh target argv, or None for unsupported source options or permission intent."""
|
|
251
|
+
built = _handoff(source_provider, target_provider, original_argv, history, prompt_override, roots)
|
|
252
|
+
return None if built is None else built[0]
|
|
183
253
|
|
|
184
254
|
|
|
185
|
-
def build_handoff(source_provider, target_provider, argv, sid, transcript, cwd, inode=None, prompt_override=None
|
|
186
|
-
|
|
255
|
+
def build_handoff(source_provider, target_provider, argv, sid, transcript, cwd, inode=None, prompt_override=None,
|
|
256
|
+
roots=None):
|
|
257
|
+
"""Validate source history and return ``{argv, history, models}`` without changing any state;
|
|
258
|
+
``models`` is the :func:`mapped_settings` result the argv was built from."""
|
|
187
259
|
history = validate_source(source_provider, sid, transcript, cwd, inode)
|
|
188
260
|
if history is None:
|
|
189
261
|
return None
|
|
190
|
-
|
|
191
|
-
return None if
|
|
262
|
+
built = _handoff(source_provider, target_provider, argv, history, prompt_override, roots)
|
|
263
|
+
return None if built is None else {"argv": built[0], "history": history, "models": built[1]}
|
package/lib/autoresume_probe.py
CHANGED
|
@@ -4,7 +4,7 @@ import re
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
ACCT_RE = re.compile(r"^acct-[A-Za-z0-9._-]{1,64}$")
|
|
7
|
-
# These errors require a different account; transient/crash may retry the same one.
|
|
7
|
+
# These errors require a different account; transient/crash/memory may retry the same one.
|
|
8
8
|
ROTATE = ("quota", "auth", "blocked", "model")
|
|
9
9
|
PROBE_DROP = {
|
|
10
10
|
"claude": ("CLAUDE_CONFIG_DIR", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_SHIM_ACTIVE",
|
|
@@ -41,7 +41,7 @@ def probe_allows(cls, pick, tier, current):
|
|
|
41
41
|
|
|
42
42
|
The shim excludes chain-avoided accounts from soft fallback, and treats exhausted
|
|
43
43
|
accounts and observed client rejections as hard. Other rotation classes keep their
|
|
44
|
-
below-threshold requirement; transient/crash may retry anywhere still usable.
|
|
44
|
+
below-threshold requirement; transient/crash/memory may retry anywhere still usable.
|
|
45
45
|
"""
|
|
46
46
|
if cls == "quota":
|
|
47
47
|
return tier in ("eligible", "soft", "credits") and bool(pick) and pick != current
|
|
@@ -48,7 +48,14 @@ def iso_epoch(value):
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def newest_error(lines, claim, org_cleared=0):
|
|
51
|
-
"""Return the newest org refusal, otherwise auth error, as (slug, ISO timestamp).
|
|
51
|
+
"""Return the newest org refusal, otherwise auth error, as (slug, ISO timestamp).
|
|
52
|
+
|
|
53
|
+
`org_cleared` is the last time `claude-accounts verify` saw a real call reach the
|
|
54
|
+
account (.client-org-cleared, written by lift_park): the credential the pool runs it
|
|
55
|
+
on authenticated, for an organization that admits Claude Code. No error recorded at
|
|
56
|
+
or before it counts. Applied to org refusals alone, an older authentication failure
|
|
57
|
+
re-parked the account that very PASS had lifted (2026-10-19).
|
|
58
|
+
"""
|
|
52
59
|
claimed_at = iso_epoch(claim)
|
|
53
60
|
if claimed_at is None:
|
|
54
61
|
return None
|
|
@@ -71,9 +78,7 @@ def newest_error(lines, claim, org_cleared=0):
|
|
|
71
78
|
code = record.get("error")
|
|
72
79
|
slug = ERRORS.get(code) if isinstance(code, str) else None
|
|
73
80
|
at = iso_epoch(record.get("timestamp"))
|
|
74
|
-
if not slug or at is None or at < claimed_at:
|
|
75
|
-
continue
|
|
76
|
-
if slug == "org-blocked" and at <= cleared_at:
|
|
81
|
+
if not slug or at is None or at < claimed_at or at <= cleared_at:
|
|
77
82
|
continue
|
|
78
83
|
if at >= latest.get(slug, (float("-inf"), ""))[0]:
|
|
79
84
|
latest[slug] = (at, record["timestamp"])
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Trust a directory in a Codex account's config.toml before an automatic relaunch starts there.
|
|
3
|
+
|
|
4
|
+
Codex (0.162) looks a folder up under ``[projects."<path>"] trust_level`` -- the canonical
|
|
5
|
+
spelling of the cwd first, then the literal cwd, then the git root's spellings -- and opens
|
|
6
|
+
on "Folder access / Trust this folder?" when no entry carries a trust level. Nobody watches a
|
|
7
|
+
relaunched pane, and the watcher never keys a TUI, so bin/codex runs this for a consumed
|
|
8
|
+
relaunch token right before its exec: the logical $PWD, its physical path and the on-disk
|
|
9
|
+
spelling macOS reports for it (Codex canonicalises with realpath(3), which returns the stored
|
|
10
|
+
case and Unicode form; Python's realpath keeps the caller's) all get ``trust_level =
|
|
11
|
+
"trusted"`` in the picked account, exactly as Codex itself writes it. An explicit
|
|
12
|
+
``"untrusted"`` answer is overridden too: it was one account's one-time dialog answer, and a
|
|
13
|
+
relaunch nobody is watching has to continue.
|
|
14
|
+
|
|
15
|
+
Standalone on purpose: the shim runs it with ``-I`` under whatever python3 the watcher found
|
|
16
|
+
(3.9 on a stock Mac), so no sibling imports, and tomllib only when present -- it then proves
|
|
17
|
+
the edit (parse before == parse after, plus the trust keys). The edit is line-based and
|
|
18
|
+
touches nothing but the one assignment: every other byte of the file, its key order, its
|
|
19
|
+
comments and its mode survive. Layouts the edit could corrupt (``projects`` defined at the
|
|
20
|
+
root as an inline table or dotted keys, a bare ``[projects]`` table, a project table defined
|
|
21
|
+
twice) are refused.
|
|
22
|
+
|
|
23
|
+
Exit 0: the directory is trusted now, or already was. Exit 1: the file was left as it was
|
|
24
|
+
(unreadable, invalid, an unsupported layout, or rewritten by someone else between the read
|
|
25
|
+
and the write); the shim logs that and the continuation still needs its prompt receipt.
|
|
26
|
+
Exit 2: usage.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import stat
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
|
|
36
|
+
LINE = 'trust_level = "trusted"\n'
|
|
37
|
+
BOM = "\ufeff"
|
|
38
|
+
HEADER = re.compile(r'^\s*\[\s*projects\s*\.\s*(?:"((?:[^"\\]|\\.)*)"|\'([^\']*)\')\s*\]\s*(?:#.*)?$')
|
|
39
|
+
ANY_HEADER = re.compile(r'^\s*\[')
|
|
40
|
+
BARE_PROJECTS = re.compile(r'^\s*\[\s*projects\s*\]')
|
|
41
|
+
ROOT_PROJECTS = re.compile(r'^\s*(?:projects|"projects"|\'projects\')\s*[.=]')
|
|
42
|
+
TRUST_KEY = re.compile(r'^\s*(?:trust_level|"trust_level"|\'trust_level\')\s*=')
|
|
43
|
+
TRUSTED = re.compile(r'^\s*(?:trust_level|"trust_level"|\'trust_level\')\s*=\s*(?:"trusted"|\'trusted\')\s*(?:#.*)?$')
|
|
44
|
+
ROOT = object()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Unsupported(Exception):
|
|
48
|
+
"""A config.toml layout this edit refuses to touch."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _decode_key(match):
|
|
52
|
+
"""The project path a ``[projects."..."]`` header names; None when its escapes are not
|
|
53
|
+
ones this reader understands (such a table is treated as somebody else's)."""
|
|
54
|
+
basic, literal = match.group(1), match.group(2)
|
|
55
|
+
if literal is not None:
|
|
56
|
+
return literal
|
|
57
|
+
try:
|
|
58
|
+
basic = re.sub(r'\\U([0-9A-Fa-f]{8})', lambda m: chr(int(m.group(1), 16)), basic)
|
|
59
|
+
return json.loads('"' + basic + '"')
|
|
60
|
+
except (ValueError, OverflowError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _scan(lines):
|
|
65
|
+
"""path -> [header line, first trust_level line or None, already trusted] for every
|
|
66
|
+
``[projects."<path>"]`` table. Raises Unsupported for the layouts the edit must not touch."""
|
|
67
|
+
found, current = {}, ROOT
|
|
68
|
+
for index, line in enumerate(lines):
|
|
69
|
+
if current is ROOT and ROOT_PROJECTS.match(line):
|
|
70
|
+
raise Unsupported("projects defined at the root")
|
|
71
|
+
if BARE_PROJECTS.match(line):
|
|
72
|
+
raise Unsupported("a bare [projects] table")
|
|
73
|
+
match = HEADER.match(line)
|
|
74
|
+
if match:
|
|
75
|
+
current = _decode_key(match)
|
|
76
|
+
if current in found:
|
|
77
|
+
raise Unsupported("a project table defined twice")
|
|
78
|
+
if current is not None:
|
|
79
|
+
found[current] = [index, None, False]
|
|
80
|
+
continue
|
|
81
|
+
if ANY_HEADER.match(line):
|
|
82
|
+
current = None
|
|
83
|
+
continue
|
|
84
|
+
if current not in (ROOT, None) and found[current][1] is None and TRUST_KEY.match(line):
|
|
85
|
+
found[current][1] = index
|
|
86
|
+
found[current][2] = bool(TRUSTED.match(line))
|
|
87
|
+
return found
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _lines(text):
|
|
91
|
+
"""Lines broken on LF only: TOML knows no other line break, while str.splitlines also
|
|
92
|
+
breaks on U+2028, U+0085 and friends, which may sit inside a string value."""
|
|
93
|
+
parts = text.split("\n")
|
|
94
|
+
lines = [part + "\n" for part in parts[:-1]]
|
|
95
|
+
if parts[-1]:
|
|
96
|
+
lines.append(parts[-1])
|
|
97
|
+
return lines
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _quote(key):
|
|
101
|
+
"""A TOML basic string for ``key``: JSON's escapes with non-ASCII left raw (a JSON
|
|
102
|
+
``\\ud83d`` surrogate pair is not TOML) and DEL escaped (raw U+007F is not either)."""
|
|
103
|
+
return json.dumps(key, ensure_ascii=False).replace("\x7f", "\\u007F")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def trusted_text(text, keys):
|
|
107
|
+
"""``text`` with ``trust_level = "trusted"`` under ``[projects."<key>"]`` for every key,
|
|
108
|
+
and whether anything had to change. An existing assignment is replaced in place, a table
|
|
109
|
+
without one gets it right under its header, a missing table is appended. Raises
|
|
110
|
+
Unsupported (see _scan)."""
|
|
111
|
+
lines = _lines(text)
|
|
112
|
+
found = _scan(lines)
|
|
113
|
+
edits, tail = [], []
|
|
114
|
+
for key in keys:
|
|
115
|
+
if key not in found:
|
|
116
|
+
if key not in tail:
|
|
117
|
+
tail.append(key)
|
|
118
|
+
continue
|
|
119
|
+
header, trust_at, trusted = found[key]
|
|
120
|
+
if not trusted:
|
|
121
|
+
edits.append((header, LINE) if trust_at is None else (trust_at, None))
|
|
122
|
+
if not edits and not tail:
|
|
123
|
+
return text, False
|
|
124
|
+
for index, insert in sorted(edits, key=lambda edit: edit[0], reverse=True):
|
|
125
|
+
if insert is None:
|
|
126
|
+
lines[index] = LINE
|
|
127
|
+
continue
|
|
128
|
+
if not lines[index].endswith("\n"):
|
|
129
|
+
lines[index] += "\n"
|
|
130
|
+
lines.insert(index + 1, insert)
|
|
131
|
+
if tail and lines and not lines[-1].endswith("\n"):
|
|
132
|
+
lines[-1] += "\n"
|
|
133
|
+
for key in tail:
|
|
134
|
+
lines.append(("\n" if lines else "") + "[projects.%s]\n%s" % (_quote(key), LINE))
|
|
135
|
+
return "".join(lines), True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _verify(before, after, keys):
|
|
139
|
+
"""With a TOML parser at hand, the edited text must parse to the original document plus
|
|
140
|
+
the trust keys and nothing else; without one the structural checks in _scan stand alone."""
|
|
141
|
+
try:
|
|
142
|
+
import tomllib
|
|
143
|
+
except ImportError:
|
|
144
|
+
try:
|
|
145
|
+
import tomli as tomllib
|
|
146
|
+
except ImportError:
|
|
147
|
+
return True
|
|
148
|
+
try:
|
|
149
|
+
expected, got = tomllib.loads(before), tomllib.loads(after)
|
|
150
|
+
except (tomllib.TOMLDecodeError, ValueError):
|
|
151
|
+
return False
|
|
152
|
+
projects = expected.setdefault("projects", {})
|
|
153
|
+
if not isinstance(projects, dict):
|
|
154
|
+
return False
|
|
155
|
+
for key in keys:
|
|
156
|
+
entry = projects.setdefault(key, {})
|
|
157
|
+
if not isinstance(entry, dict):
|
|
158
|
+
return False
|
|
159
|
+
entry["trust_level"] = "trusted"
|
|
160
|
+
return got == expected
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _write(path, text, st):
|
|
164
|
+
"""Atomic replace keeping the file's mode; a new file is created 0600 and only if nobody
|
|
165
|
+
created it meanwhile. A file rewritten since it was read (Codex writes its own config
|
|
166
|
+
whenever it likes) is left alone rather than lost to a stale copy."""
|
|
167
|
+
if st is None:
|
|
168
|
+
try:
|
|
169
|
+
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
170
|
+
with os.fdopen(handle, "w", encoding="utf-8") as fh:
|
|
171
|
+
fh.write(text)
|
|
172
|
+
return True
|
|
173
|
+
except OSError:
|
|
174
|
+
return False
|
|
175
|
+
try:
|
|
176
|
+
handle, scratch = tempfile.mkstemp(prefix=".config.toml.", dir=os.path.dirname(path))
|
|
177
|
+
except OSError:
|
|
178
|
+
return False
|
|
179
|
+
try:
|
|
180
|
+
with os.fdopen(handle, "w", encoding="utf-8") as fh:
|
|
181
|
+
os.fchmod(fh.fileno(), stat.S_IMODE(st.st_mode))
|
|
182
|
+
fh.write(text)
|
|
183
|
+
now = os.stat(path)
|
|
184
|
+
if (now.st_mtime_ns, now.st_size, now.st_ino) != (st.st_mtime_ns, st.st_size, st.st_ino):
|
|
185
|
+
raise OSError("config.toml changed meanwhile")
|
|
186
|
+
os.replace(scratch, path)
|
|
187
|
+
return True
|
|
188
|
+
except OSError:
|
|
189
|
+
try:
|
|
190
|
+
os.unlink(scratch)
|
|
191
|
+
except OSError:
|
|
192
|
+
pass
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def spellings(cwd):
|
|
197
|
+
"""The keys Codex may look the directory up under: as given, Python's realpath, and the
|
|
198
|
+
on-disk spelling (macOS F_GETPATH; the form realpath(3) returns there)."""
|
|
199
|
+
keys = [cwd]
|
|
200
|
+
real = os.path.realpath(cwd)
|
|
201
|
+
if real not in keys:
|
|
202
|
+
keys.append(real)
|
|
203
|
+
try:
|
|
204
|
+
import fcntl
|
|
205
|
+
handle = os.open(real, os.O_RDONLY)
|
|
206
|
+
try:
|
|
207
|
+
disk = os.fsdecode(fcntl.fcntl(handle, fcntl.F_GETPATH, b"\0" * 1024)).rstrip("\0")
|
|
208
|
+
finally:
|
|
209
|
+
os.close(handle)
|
|
210
|
+
if disk and disk not in keys:
|
|
211
|
+
keys.append(disk)
|
|
212
|
+
except (ImportError, AttributeError, OSError, UnicodeError):
|
|
213
|
+
pass
|
|
214
|
+
return keys
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def trust(acct_dir, cwd):
|
|
218
|
+
"""Exit status for ``--acct-dir <acct_dir> --cwd <cwd>`` (see the module docstring)."""
|
|
219
|
+
if not os.path.isabs(cwd or "") or not os.path.isdir(acct_dir or ""):
|
|
220
|
+
return 1
|
|
221
|
+
path = os.path.join(acct_dir, "config.toml")
|
|
222
|
+
st, text = None, ""
|
|
223
|
+
try:
|
|
224
|
+
if os.path.lexists(path):
|
|
225
|
+
path = os.path.realpath(path)
|
|
226
|
+
if not stat.S_ISREG(os.stat(path).st_mode):
|
|
227
|
+
return 1 # a FIFO or a device would hang the relaunch on open()
|
|
228
|
+
with open(path, "rb") as fh:
|
|
229
|
+
st = os.fstat(fh.fileno())
|
|
230
|
+
text = fh.read().decode("utf-8")
|
|
231
|
+
except FileNotFoundError:
|
|
232
|
+
pass
|
|
233
|
+
except (OSError, UnicodeDecodeError):
|
|
234
|
+
return 1
|
|
235
|
+
bom = text.startswith(BOM)
|
|
236
|
+
text = text[len(BOM):] if bom else text
|
|
237
|
+
try:
|
|
238
|
+
result, changed = trusted_text(text, spellings(cwd))
|
|
239
|
+
except Unsupported:
|
|
240
|
+
return 1
|
|
241
|
+
if not changed:
|
|
242
|
+
return 0
|
|
243
|
+
if not _verify(text, result, spellings(cwd)):
|
|
244
|
+
return 1
|
|
245
|
+
return 0 if _write(path, (BOM if bom else "") + result, st) else 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main(argv):
|
|
249
|
+
opts, i = {}, 0
|
|
250
|
+
while i < len(argv):
|
|
251
|
+
if argv[i] not in ("--acct-dir", "--cwd") or i + 1 >= len(argv) or argv[i] in opts:
|
|
252
|
+
opts = None
|
|
253
|
+
break
|
|
254
|
+
opts[argv[i]] = argv[i + 1]
|
|
255
|
+
i += 2
|
|
256
|
+
if opts is None or len(opts) != 2:
|
|
257
|
+
sys.stderr.write("usage: codex_trust.py --acct-dir <account dir> --cwd <directory>\n")
|
|
258
|
+
return 2
|
|
259
|
+
return trust(opts["--acct-dir"], opts["--cwd"])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
sys.exit(main(sys.argv[1:]))
|
package/lib/common.sh
CHANGED
|
@@ -139,6 +139,25 @@ pkg_version_at() {
|
|
|
139
139
|
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/package.json" | head -1
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
# Whether version $1 is NEWER than $2: dotted numbers compared part by part, a missing part
|
|
143
|
+
# counting as 0. False for anything else (empty, a suffix, a part over nine digits): only a
|
|
144
|
+
# version this can read is ever called newer. Builtins only.
|
|
145
|
+
ver_newer() { # ver_newer <a> <b>
|
|
146
|
+
local a="$1" b="$2" x y
|
|
147
|
+
case "$a" in ''|*[!0-9.]*|.*|*.|*..*) return 1 ;; esac
|
|
148
|
+
case "$b" in ''|*[!0-9.]*|.*|*.|*..*) return 1 ;; esac
|
|
149
|
+
while [ -n "$a$b" ]; do
|
|
150
|
+
x="${a%%.*}"; y="${b%%.*}"
|
|
151
|
+
case "$a" in *.*) a="${a#*.}" ;; *) a="" ;; esac
|
|
152
|
+
case "$b" in *.*) b="${b#*.}" ;; *) b="" ;; esac
|
|
153
|
+
[ "${#x}" -le 9 ] && [ "${#y}" -le 9 ] || return 1
|
|
154
|
+
x=$((10#${x:-0})); y=$((10#${y:-0}))
|
|
155
|
+
[ "$x" -gt "$y" ] && return 0
|
|
156
|
+
[ "$x" -lt "$y" ] && return 1
|
|
157
|
+
done
|
|
158
|
+
return 1
|
|
159
|
+
}
|
|
160
|
+
|
|
142
161
|
# The npm that owns THIS install, resolved without trusting the ambient PATH.
|
|
143
162
|
# A launchd agent (and a bare cron) runs with PATH=/usr/bin:/bin:/usr/sbin:/sbin —
|
|
144
163
|
# no homebrew, no nvm — so `command -v npm` fails there and the daily self-update
|
|
@@ -465,23 +484,37 @@ has_local_auth() { # $1 = acct dir
|
|
|
465
484
|
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
|
|
466
485
|
[ -s "$1/auth.json" ]
|
|
467
486
|
else
|
|
468
|
-
|
|
487
|
+
creds_file_has_login "$1/.credentials.json" || [ -s "$1/server.token" ] \
|
|
488
|
+
|| keychain_has_login "$1"
|
|
469
489
|
fi
|
|
470
490
|
}
|
|
471
491
|
|
|
492
|
+
# A claude credentials document is a LOGIN only when it carries a `claudeAiOauth` object.
|
|
493
|
+
# The client keeps its MCP servers' OAuth state (`mcpOAuth`) in the same file, and from a
|
|
494
|
+
# session that cannot open the Keychain it writes one holding ONLY that (2.1.303): no
|
|
495
|
+
# login, whatever its entries' own expiry says. bin/claude's file_login is the same
|
|
496
|
+
# check (the shim sources nothing); lib/audit.py file_has_login is the python twin.
|
|
497
|
+
creds_file_has_login() { # $1 = a .credentials.json path
|
|
498
|
+
local doc="" LC_ALL=C re='"claudeAiOauth"[[:space:]]*:[[:space:]]*[{]'
|
|
499
|
+
[ -s "$1" ] || return 1
|
|
500
|
+
{ doc="$(< "$1")"; } 2>/dev/null
|
|
501
|
+
[[ $doc =~ $re ]]
|
|
502
|
+
}
|
|
503
|
+
|
|
472
504
|
# ---- macOS Keychain-held logins (claude only) ------------------------------------
|
|
473
505
|
# Claude Code on macOS moves a config dir's OAuth login into the login Keychain the
|
|
474
506
|
# first time a GUI-session process can write there, deleting .credentials.json as it
|
|
475
507
|
# goes (lib/keychain.py has the whole story). These wrap that module so bash callers
|
|
476
508
|
# never parse `security` output themselves. Each one is a no-op (absent) off macOS.
|
|
477
|
-
keychain_state() { # $1 = acct dir -> prints present|locked|absent|corrupt|unavailable
|
|
509
|
+
keychain_state() { # $1 = acct dir -> prints present|nologin|locked|absent|corrupt|unavailable
|
|
478
510
|
local state
|
|
479
511
|
[ "$MULTIACC_PROVIDER" = "codex" ] && { echo absent; return 0; }
|
|
480
512
|
state="$("$PYBIN" "$LIB_DIR/keychain.py" probe "$1" 2>/dev/null \
|
|
481
513
|
| LC_ALL=C sed -n 's/.*"state": *"\([a-z]*\)".*/\1/p' | head -1)"
|
|
482
514
|
printf '%s\n' "${state:-unavailable}"
|
|
483
515
|
}
|
|
484
|
-
keychain_has_login() { # $1 = acct dir:
|
|
516
|
+
keychain_has_login() { # $1 = acct dir: a login item EXISTS here (readable or locked); an
|
|
517
|
+
# item holding MCP state alone ('nologin') is none
|
|
485
518
|
case "$(keychain_state "$1")" in present|locked|corrupt) return 0 ;; *) return 1 ;; esac
|
|
486
519
|
}
|
|
487
520
|
keychain_forget() { # $1 = acct dir: drop every item (a removed account must not leave a grant behind)
|
|
@@ -738,6 +771,21 @@ seed_mcp_registry() { # $1 = acct dir
|
|
|
738
771
|
return 0
|
|
739
772
|
}
|
|
740
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
|
+
|
|
741
789
|
seed_account_dir() { # $1 = acct dir
|
|
742
790
|
local d="$1"
|
|
743
791
|
mkdir -p "$d"
|
|
@@ -831,3 +879,98 @@ rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~2
|
|
|
831
879
|
tail -c 262144 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f"
|
|
832
880
|
fi
|
|
833
881
|
}
|
|
882
|
+
|
|
883
|
+
# ---- the model/effort catalog (lib/model_catalog.py) --------------------------------
|
|
884
|
+
# Auto-resume relaunches a session with the model and effort it ran with, mapped onto the
|
|
885
|
+
# other provider's lineup when it changes provider. The mapping reads a catalog: the copy
|
|
886
|
+
# shipped beside lib/model_catalog.py (a snapshot of the lineup, the fallback) merged with the model
|
|
887
|
+
# caches the CLIs themselves keep in their account dirs, written to <pool>/model-catalog.json.
|
|
888
|
+
# Nothing here sends a request or reads a credential. Every install and self-update refreshes
|
|
889
|
+
# both pools; each limits pass refreshes its own pool once that copy is an hour old.
|
|
890
|
+
#
|
|
891
|
+
# <PROVIDER>_MULTIACC_MODEL_CATALOG=0 (false/no/off alike) stops every refresh of that pool's
|
|
892
|
+
# copy, MULTIACC_MODEL_CATALOG=0 of both; the provider's own spelling wins when it is set.
|
|
893
|
+
MODEL_CATALOG_OFF_BY=""
|
|
894
|
+
model_catalog_on() { # $1 = claude|codex; when off, MODEL_CATALOG_OFF_BY names the deciding switch
|
|
895
|
+
local name v
|
|
896
|
+
if [ "$1" = codex ]; then name=CODEX_MULTIACC_MODEL_CATALOG v="${CODEX_MULTIACC_MODEL_CATALOG:-}"
|
|
897
|
+
else name=CLAUDE_MULTIACC_MODEL_CATALOG v="${CLAUDE_MULTIACC_MODEL_CATALOG:-}"; fi
|
|
898
|
+
[ -n "$v" ] || { name=MULTIACC_MODEL_CATALOG; v="${MULTIACC_MODEL_CATALOG:-1}"; }
|
|
899
|
+
case "$v" in
|
|
900
|
+
0|false|no|off|False|No|Off|FALSE|NO|OFF) MODEL_CATALOG_OFF_BY="$name=$v"; return 1 ;;
|
|
901
|
+
esac
|
|
902
|
+
return 0
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
model_catalog_root() { # $1 = claude|codex: that pool's root, by the same precedence as ACC_ROOT
|
|
906
|
+
if [ "$1" = codex ]; then printf '%s\n' "${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
|
|
907
|
+
else printf '%s\n' "${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}}"; fi
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
# `<provider>-accounts model-catalog [refresh|show] [--if-stale] [--quiet]`, the same in both
|
|
911
|
+
# CLIs. refresh (the default) writes both pools' copies, leaving out a pool whose switch is off;
|
|
912
|
+
# --if-stale skips a copy younger than an hour; the status is lib/model_catalog.py's (1 = nothing
|
|
913
|
+
# could be written). show prints the merged catalog. No manifest needed: the installer runs it.
|
|
914
|
+
cmd_model_catalog() {
|
|
915
|
+
local sub="" stale="" quiet=0 p rc=0
|
|
916
|
+
local roots=()
|
|
917
|
+
while [ $# -gt 0 ]; do
|
|
918
|
+
case "$1" in
|
|
919
|
+
refresh|show) [ -z "$sub" ] || die "model-catalog takes one of refresh or show"; sub="$1" ;;
|
|
920
|
+
--if-stale) stale=--if-stale ;;
|
|
921
|
+
--quiet) quiet=1 ;;
|
|
922
|
+
*) die "unknown model-catalog argument: $1 (refresh, show, --if-stale, --quiet)" ;;
|
|
923
|
+
esac
|
|
924
|
+
shift
|
|
925
|
+
done
|
|
926
|
+
[ -f "$LIB_DIR/model_catalog.py" ] || die "lib/model_catalog.py is missing from this install"
|
|
927
|
+
if [ "$sub" = show ]; then
|
|
928
|
+
"$PYBIN" -I "$LIB_DIR/model_catalog.py" show --claude-root "$(model_catalog_root claude)" \
|
|
929
|
+
--codex-root "$(model_catalog_root codex)"
|
|
930
|
+
return $?
|
|
931
|
+
fi
|
|
932
|
+
for p in claude codex; do
|
|
933
|
+
if model_catalog_on "$p"; then
|
|
934
|
+
roots+=("--$p-root" "$(model_catalog_root "$p")")
|
|
935
|
+
elif [ "$quiet" = 0 ]; then
|
|
936
|
+
printf '%s: model catalog refresh is off for the %s pool (%s); nothing written there\n' \
|
|
937
|
+
"$PROVIDER_CLI" "$p" "$MODEL_CATALOG_OFF_BY" >&2
|
|
938
|
+
fi
|
|
939
|
+
done
|
|
940
|
+
[ ${#roots[@]} -gt 0 ] || return 0
|
|
941
|
+
if [ "$quiet" = 1 ]; then
|
|
942
|
+
"$PYBIN" -I "$LIB_DIR/model_catalog.py" refresh ${stale:+"$stale"} "${roots[@]}" >/dev/null 2>&1 || rc=$?
|
|
943
|
+
else
|
|
944
|
+
"$PYBIN" -I "$LIB_DIR/model_catalog.py" refresh ${stale:+"$stale"} "${roots[@]}" || rc=$?
|
|
945
|
+
[ "$rc" = 1 ] && warn "no pool root could take the model catalog (none exists? run claude-multiacc install)"
|
|
946
|
+
fi
|
|
947
|
+
return "$rc"
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
# The limits passes' backstop: app-robot moves its git tag and docker updates npm without ever
|
|
951
|
+
# running install.sh, so every pass keeps ITS OWN pool's copy within an hour of its sources
|
|
952
|
+
# (python's --if-stale leaves a younger copy alone). Its own root only: a pass never writes the
|
|
953
|
+
# other provider's pool, which a test sandboxing one pool would inherit from the operator. In
|
|
954
|
+
# line, not detached: a detached writer outlives the pass, into a pool its caller may already be
|
|
955
|
+
# deleting. Never changes the pass's status.
|
|
956
|
+
model_catalog_backstop() {
|
|
957
|
+
model_catalog_on "$MULTIACC_PROVIDER" || return 0
|
|
958
|
+
[ -f "$LIB_DIR/model_catalog.py" ] || return 0
|
|
959
|
+
"$PYBIN" -I "$LIB_DIR/model_catalog.py" refresh --if-stale "--$MULTIACC_PROVIDER-root" "$ACC_ROOT" \
|
|
960
|
+
</dev/null >/dev/null 2>&1 || true
|
|
961
|
+
return 0
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
# After an npm update the NEW tree refreshes the catalog, in a process of its own: this bash
|
|
965
|
+
# still runs the code it started with, and the catalog to merge is the one the new release
|
|
966
|
+
# ships. Logged to update.log; it never changes the update's exit status.
|
|
967
|
+
model_catalog_after_update() { # $1 = 1 when self-update runs --quiet
|
|
968
|
+
local rc=0 line
|
|
969
|
+
model_catalog_on claude || model_catalog_on codex || return 0
|
|
970
|
+
"$REPO_DIR/bin/$PROVIDER_CLI" model-catalog refresh --quiet </dev/null >>"$ACC_ROOT/update.log" 2>&1 || rc=$?
|
|
971
|
+
if [ "$rc" = 0 ]; then line="self-update: model catalog refreshed"
|
|
972
|
+
else line="self-update: model catalog refresh FAILED (rc=$rc); the limits passes retry within the hour"; fi
|
|
973
|
+
log_to update.log "$line"
|
|
974
|
+
[ "${1:-0}" = 1 ] || echo "$line"
|
|
975
|
+
return 0
|
|
976
|
+
}
|
package/lib/credential.py
CHANGED
|
@@ -221,10 +221,13 @@ def cmd_export(argv):
|
|
|
221
221
|
has_token = os.path.isfile(tpath) and os.path.getsize(tpath) > 0
|
|
222
222
|
if not has_token:
|
|
223
223
|
where = None
|
|
224
|
-
|
|
224
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
225
|
+
# A login, not any credentials document: one holding only the client's MCP
|
|
226
|
+
# OAuth state is no credential material for this account (audit.file_has_login).
|
|
227
|
+
from audit import file_has_login
|
|
228
|
+
if file_has_login(cpath):
|
|
225
229
|
where = '.credentials.json'
|
|
226
230
|
else:
|
|
227
|
-
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
228
231
|
import keychain
|
|
229
232
|
if keychain.probe(d)['state'] in ('present', 'locked', 'corrupt'):
|
|
230
233
|
where = 'macOS Keychain'
|