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.
Files changed (151) hide show
  1. package/README.md +125 -4
  2. package/bin/claude +389 -94
  3. package/bin/claude-accounts +276 -42
  4. package/bin/codex +69 -5
  5. package/bin/codex-accounts +33 -4
  6. package/docs/ACCOUNT_OPERATIONS.md +62 -2
  7. package/docs/AUTORESUME-HANDOFF.md +28 -3
  8. package/docs/AUTORESUME.md +275 -18
  9. package/docs/CODEX.md +1 -0
  10. package/docs/INSTALLATION.md +7 -1
  11. package/docs/VERIFICATION.md +4 -0
  12. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  45. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  46. package/lib/audit.py +88 -22
  47. package/lib/autoresume.py +1197 -82
  48. package/lib/autoresume_cross.py +3 -1
  49. package/lib/autoresume_handoff.py +81 -9
  50. package/lib/autoresume_probe.py +2 -2
  51. package/lib/claude_client_error.py +9 -4
  52. package/lib/codex_trust.py +263 -0
  53. package/lib/common.sh +146 -3
  54. package/lib/credential.py +5 -2
  55. package/lib/install_actions.sh +19 -0
  56. package/lib/keychain.py +180 -36
  57. package/lib/mcp_oauth.py +1748 -0
  58. package/lib/mcp_registry.py +230 -8
  59. package/lib/model_catalog.json +404 -0
  60. package/lib/model_catalog.py +201 -0
  61. package/lib/model_match.py +104 -0
  62. package/lib/report.py +8 -5
  63. package/lib/session_settings.py +123 -0
  64. package/package.json +2 -2
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  122. package/tests/fake_codex_tui.py +25 -0
  123. package/tests/fake_security.sh +24 -2
  124. package/tests/fixtures/cross_provider_cli.py +109 -0
  125. package/tests/run-tests.sh +1171 -23
  126. package/tests/test_autoresume.py +215 -8
  127. package/tests/test_autoresume_catalog.py +226 -0
  128. package/tests/test_autoresume_catalog_cli.py +185 -0
  129. package/tests/test_autoresume_codex_records.py +16 -0
  130. package/tests/test_autoresume_codex_trust.py +247 -0
  131. package/tests/test_autoresume_cross_guards.py +28 -0
  132. package/tests/test_autoresume_cross_tmux.py +27 -2
  133. package/tests/test_autoresume_cross_tokens.py +32 -2
  134. package/tests/test_autoresume_handoff_argv.py +120 -16
  135. package/tests/test_autoresume_handoff_origins.py +14 -0
  136. package/tests/test_autoresume_memory.py +1141 -0
  137. package/tests/test_autoresume_memory_tmux.py +94 -0
  138. package/tests/test_autoresume_probe.py +2 -1
  139. package/tests/test_autoresume_relaunch_settings.py +317 -0
  140. package/tests/test_autoresume_settings.py +135 -0
  141. package/tests/test_autoresume_sidechain.py +1518 -0
  142. package/tests/test_autoresume_sidechain_tmux.py +79 -0
  143. package/tests/test_autoresume_trust_tmux.py +63 -0
  144. package/tests/test_claude_client_error.py +10 -7
  145. package/tests/test_claude_org_block.py +1 -0
  146. package/tests/test_claude_pool_prepare.py +4 -1
  147. package/tests/test_claude_reset.py +3 -0
  148. package/tests/test_keychain_nologin.py +340 -0
  149. package/tests/test_mcp_oauth.py +1414 -0
  150. package/tests/test_mcp_registry.py +234 -3
  151. package/tests/test_packaged_commands.py +41 -0
@@ -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 models or provider-specific resume flags."""
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 handoff_prompt(history, target, prompt_override=None):
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 handoff_argv(source_provider, target_provider, original_argv, history, prompt_override=None):
168
- """Fresh target argv, or None for unsupported source options or permission intent."""
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
- return None if options is None else options + [handoff_prompt(history, target_provider, prompt_override)]
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
- """Validate source history and return ``{argv, history}`` without changing any state."""
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
- target_argv = handoff_argv(source_provider, target_provider, argv, history, prompt_override)
191
- return None if target_argv is None else {"argv": target_argv, "history": history}
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]}
@@ -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
- [ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ] || keychain_has_login "$1"
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: an item EXISTS here (readable or locked)
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
- if os.path.isfile(cpath) and os.path.getsize(cpath) > 0:
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'