claude-multiacc 2.0.61 → 2.0.65

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 (244) hide show
  1. package/README.md +6 -2
  2. package/bin/claude-accounts +4 -4
  3. package/bin/codex +54 -13
  4. package/bin/codex-accounts +28 -7
  5. package/docs/ACCOUNT_OPERATIONS.md +2 -2
  6. package/docs/AUTORESUME.md +16 -2
  7. package/docs/CODEX.md +58 -2
  8. package/docs/CODEX_MCP_AUTH.md +58 -0
  9. package/lib/__pycache__/audit.cpython-314.pyc +0 -0
  10. package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
  11. package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
  12. package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
  13. package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
  14. package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
  15. package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
  16. package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
  17. package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
  18. package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
  19. package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
  20. package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
  21. package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
  22. package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
  23. package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
  24. package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
  25. package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
  26. package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
  27. package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
  28. package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
  29. package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
  30. package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
  31. package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
  32. package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
  33. package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
  34. package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
  35. package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
  36. package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
  37. package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
  38. package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
  39. package/lib/__pycache__/report.cpython-314.pyc +0 -0
  40. package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
  41. package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
  42. package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
  43. package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
  44. package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
  45. package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
  46. package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
  47. package/lib/autoresume.py +34 -13
  48. package/lib/autoresume_cross.py +2 -1
  49. package/lib/autoresume_handoff.py +13 -5
  50. package/lib/codex_exec_stdin.sh +45 -0
  51. package/lib/codex_mcp_oauth.py +204 -0
  52. package/lib/codex_mcp_oauth.sh +29 -0
  53. package/lib/codex_mcp_oauth_locks.py +158 -0
  54. package/lib/codex_mcp_oauth_native.py +102 -0
  55. package/lib/codex_mcp_oauth_store.py +149 -0
  56. package/lib/codex_resume_cwd.py +57 -0
  57. package/lib/codex_retry_process.sh +37 -0
  58. package/lib/codex_speed.py +351 -0
  59. package/lib/codex_speed.sh +39 -0
  60. package/lib/common.sh +16 -3
  61. package/lib/model_match.py +4 -1
  62. package/lib/session_settings.py +25 -6
  63. package/lib/shim_path.py +6 -0
  64. package/package.json +10 -5
  65. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
  66. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
  67. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
  68. package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
  69. package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
  70. package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
  99. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
  100. package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
  101. package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
  102. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
  103. package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
  104. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
  105. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
  106. package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
  107. package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
  108. package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
  109. package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
  111. package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
  112. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
  113. package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
  114. package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
  115. package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
  116. package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
  117. package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
  118. package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
  119. package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
  120. package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
  121. package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
  122. package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
  123. package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
  124. package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
  125. package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
  126. package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
  127. package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
  128. package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
  129. package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
  130. package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
  131. package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
  132. package/tests/fake_codex_tui.py +23 -1
  133. package/tests/run-tests.sh +23 -7
  134. package/tests/test_autoresume_cwd.py +71 -0
  135. package/tests/test_autoresume_cwd_handoff.py +92 -0
  136. package/tests/test_autoresume_cwd_reader.py +105 -0
  137. package/tests/test_autoresume_cwd_tmux.py +54 -0
  138. package/tests/test_codex_exec_stdin.py +178 -0
  139. package/tests/test_codex_mcp_oauth.py +146 -0
  140. package/tests/test_codex_mcp_oauth_adopt.py +87 -0
  141. package/tests/test_codex_mcp_oauth_migration.py +141 -0
  142. package/tests/test_codex_mcp_oauth_native.py +109 -0
  143. package/tests/test_codex_mcp_oauth_safety.py +130 -0
  144. package/tests/test_codex_mcp_oauth_shim.py +188 -0
  145. package/tests/test_codex_retry_process.py +143 -0
  146. package/tests/test_codex_speed.py +236 -0
  147. package/tests/test_mutate_lock.py +107 -0
  148. package/tests/test_shim_path.py +44 -4
  149. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  150. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  151. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  152. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  153. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  154. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  155. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  156. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  157. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  158. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  159. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  160. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  161. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  162. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  163. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  164. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  165. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  166. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  167. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  168. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  169. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  170. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  171. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  172. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  173. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  174. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  175. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  176. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  177. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  178. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  179. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  180. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  181. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  182. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  183. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  184. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  185. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  186. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  187. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  188. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  189. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  190. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  191. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  192. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  193. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  194. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  195. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  196. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  197. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  198. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  199. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  200. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  201. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  202. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  203. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  204. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  205. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  206. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  207. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  208. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  209. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  210. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  211. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  212. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  213. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  214. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  215. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  216. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  217. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  218. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  219. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  220. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  221. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  222. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  223. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  224. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  225. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  226. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  227. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  228. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  229. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  230. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  231. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  232. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  233. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  234. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  235. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  236. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  237. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  238. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  239. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  240. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  241. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  242. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  243. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  244. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
package/lib/autoresume.py CHANGED
@@ -69,6 +69,7 @@ from autoresume_cross_receipt import confirm_cross
69
69
  from model_catalog import load as load_catalog, parse_id
70
70
  from model_match import resume_settings, settings_flags
71
71
  from session_settings import session_settings
72
+ from codex_resume_cwd import codex_resume_cwd
72
73
 
73
74
  # ---- vocabulary ---------------------------------------------------------------------
74
75
 
@@ -1213,6 +1214,14 @@ def codex_effort_ok(val):
1213
1214
  and EFFORT_VALUE_RE.match(val[len(CODEX_EFFORT_KEY):]) is not None
1214
1215
 
1215
1216
 
1217
+ def codex_speed_ok(val):
1218
+ """Only known speed choices, never arbitrary config, may cross a relaunch."""
1219
+ if val == "features.fast_mode=true":
1220
+ return True
1221
+ return isinstance(val, str) and re.fullmatch(
1222
+ r'''service_tier=(?:default|fast|priority|ultrafast|"(?:default|fast|priority|ultrafast)"|'(?:default|fast|priority|ultrafast)')''', val) is not None
1223
+
1224
+
1216
1225
  def codex_argv_parse(argv):
1217
1226
  """The argv shapes a codex relaunch can rebuild (mirrors bin/codex's ar_argv_ok) ->
1218
1227
  (kept options, resume target or None), or None for anything else."""
@@ -1230,7 +1239,7 @@ def codex_argv_parse(argv):
1230
1239
  kept += [arg, expected]
1231
1240
  i += 1
1232
1241
  elif arg in ("-m", "--model", "-c"):
1233
- value_ok = codex_effort_ok if arg == "-c" else _value_ok
1242
+ value_ok = (lambda value: codex_effort_ok(value) or codex_speed_ok(value)) if arg == "-c" else _value_ok
1234
1243
  if i + 1 >= len(argv) or not value_ok(argv[i + 1]):
1235
1244
  return None
1236
1245
  kept += [arg, argv[i + 1]]
@@ -1314,7 +1323,11 @@ def settings_argv(provider, settings):
1314
1323
  (anything else counts as unknown). [] when nothing is known."""
1315
1324
  effort = settings.get("effort") if isinstance(settings, dict) else None
1316
1325
  effort = effort if isinstance(effort, str) and EFFORT_VALUE_RE.match(effort) else None
1317
- return settings_flags(provider, history_model(settings), effort)
1326
+ flags = settings_flags(provider, history_model(settings), effort)
1327
+ if provider == "codex" and isinstance(settings, dict) and settings.get("speed") in ("normal", "fast", "ultrafast"):
1328
+ flags += ["-c", "service_tier=" + {"normal": "default", "fast": "fast",
1329
+ "ultrafast": "ultrafast"}[settings["speed"]]]
1330
+ return flags
1318
1331
 
1319
1332
 
1320
1333
  def argv_settings(provider, argv):
@@ -1335,6 +1348,10 @@ def argv_settings(provider, argv):
1335
1348
  elif provider == "codex" and arg == "-c" and codex_effort_ok(val):
1336
1349
  found["effort"] = val[len(CODEX_EFFORT_KEY):]
1337
1350
  i += 1
1351
+ elif provider == "codex" and arg == "-c" and val.startswith("service_tier=") and codex_speed_ok(val):
1352
+ from codex_speed import speed
1353
+ found["speed"] = speed(val.split("=", 1)[1].strip("\"'"))
1354
+ i += 1
1338
1355
  i += 1
1339
1356
  return found
1340
1357
 
@@ -1354,14 +1371,16 @@ def claude_model_left(launch, served):
1354
1371
  return family is not None and family != launch
1355
1372
 
1356
1373
 
1357
- def without_settings(kept, model, effort):
1374
+ def without_settings(kept, model, effort, speed=False):
1358
1375
  """``kept`` without its model options (-m V, --model V, --model=V) when ``model`` and its
1359
1376
  effort options (--effort V, codex's -c model_reasoning_effort=V) when ``effort``. Both
1360
1377
  parsers refuse a value that starts with "-", so no value is ever taken for an option."""
1361
1378
  out, i = [], 0
1362
1379
  while i < len(kept):
1363
1380
  arg = kept[i]
1364
- if (model and arg in ("-m", "--model")) or (effort and arg in ("--effort", "-c")):
1381
+ val = kept[i + 1] if i + 1 < len(kept) else ""
1382
+ if ((model and arg in ("-m", "--model")) or (effort and arg == "--effort")
1383
+ or (arg == "-c" and ((effort and codex_effort_ok(val)) or (speed and codex_speed_ok(val))))):
1365
1384
  i += 2
1366
1385
  continue
1367
1386
  if not (model and arg.startswith("--model=")):
@@ -1406,10 +1425,12 @@ def relaunch_argv(provider, argv, sid, cls, prompt=None, settings=None, detail=N
1406
1425
  left = claude_model_left(argv_settings("claude", kept).get("model"), settings.get("model"))
1407
1426
  settings = {"effort": settings.get("effort")}
1408
1427
  flags = settings_argv(provider, settings)
1409
- model, effort = left or "--model" in flags or "-m" in flags, "--effort" in flags or "-c" in flags
1410
- if provider == "codex" and not flags and history_model(settings):
1428
+ model = left or "--model" in flags or "-m" in flags
1429
+ effort = "--effort" in flags or any(codex_effort_ok(value) for value in flags)
1430
+ speed = provider == "codex" and settings.get("speed") in ("normal", "fast", "ultrafast")
1431
+ if provider == "codex" and not model and history_model(settings):
1411
1432
  model = effort = True # no launch -m or -c alone: neither, and the thread restores both
1412
- return head + without_settings(kept, model, effort) + flags + tail
1433
+ return head + without_settings(kept, model, effort, speed) + flags + tail
1413
1434
 
1414
1435
 
1415
1436
  # ---- relaunch / state files ---------------------------------------------------------
@@ -2698,11 +2719,11 @@ class Watcher:
2698
2719
  return self.busy_memo[1]
2699
2720
 
2700
2721
  def session_cwd(self):
2701
- """Where the probe and the relaunch run. claude: the registry's cwd first
2702
- (``--resume`` finds the transcript by it). codex: the launch directory first, so
2703
- the relaunch repeats the user's own invocation; the rollout's cwd after it."""
2704
- order = (self.cwd, self.st["cwd"]) if self.provider == "claude" \
2705
- else (self.st["cwd"], self.cwd)
2722
+ """Restore the session's directory before the picked account trusts and resumes it.
2723
+ Codex's saved cwd can differ from both the launching shell and initial metadata."""
2724
+ order = (self.cwd, self.st["cwd"])
2725
+ if self.provider == "codex":
2726
+ order = (codex_resume_cwd(self.transcript, self.sid),) + order
2706
2727
  for cand in order:
2707
2728
  if cand and os.path.isdir(cand):
2708
2729
  return cand
@@ -2720,7 +2741,7 @@ class Watcher:
2720
2741
  try:
2721
2742
  found = session_settings(self.provider, self.transcript)
2722
2743
  self.served_model = history_model(found)
2723
- if not found.get("model") and not found.get("effort"):
2744
+ if not found.get("model") and not found.get("effort") and not found.get("speed"):
2724
2745
  return None
2725
2746
  # The catalog only clamps an effort to the model and supplies Codex's default
2726
2747
  # one: this pool's copy and caches, the other pool's only when the chain named it.
@@ -110,7 +110,8 @@ def revalidate(watcher, plan):
110
110
  history = plan['history']
111
111
  current = validate_source(watcher.provider, watcher.sid, watcher.transcript,
112
112
  watcher.session_cwd(), inode=history['inode'])
113
- return (current is not None and current['offset'] >= history['offset'] and not shared_source(watcher)
113
+ return (current is not None and current['offset'] >= history['offset'] and current['cwd'] == history['cwd']
114
+ and not shared_source(watcher)
114
115
  and all(enabled(watcher, provider, root) for provider, root in plan['roots'].items()))
115
116
 
116
117
 
@@ -12,6 +12,8 @@ import os
12
12
  import re
13
13
  import stat
14
14
 
15
+ from codex_resume_cwd import codex_resume_cwd
16
+
15
17
 
16
18
  PROVIDERS = ("claude", "codex")
17
19
  SID_RE = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
@@ -48,8 +50,8 @@ def _read_source(path, inode):
48
50
  return None
49
51
 
50
52
 
51
- def _codex_identity(data, sid, cwd):
52
- """A main interactive rollout identifies itself in its first JSONL record."""
53
+ def _codex_identity(data, sid, cwd, path, identity):
54
+ """Bind main-thread identity and its saved cwd at the validated snapshot boundary."""
53
55
  try:
54
56
  record = json.loads(data.split(b"\n", 1)[0])
55
57
  except (ValueError, UnicodeError):
@@ -63,8 +65,14 @@ def _codex_identity(data, sid, cwd):
63
65
  return False
64
66
  if meta.get("originator") not in (None, "codex-tui", "codex_cli_rs", "codex_cli"):
65
67
  return False
66
- directory = meta.get("cwd")
67
- return isinstance(directory, str) and os.path.isabs(directory) and os.path.realpath(directory) == cwd
68
+ try:
69
+ directory = codex_resume_cwd(path, sid, upto=identity.st_size)
70
+ after = os.stat(path)
71
+ except OSError:
72
+ return False
73
+ return (directory is not None and os.path.realpath(directory) == cwd
74
+ and (after.st_dev, after.st_ino) == (identity.st_dev, identity.st_ino)
75
+ and after.st_size >= identity.st_size)
68
76
 
69
77
 
70
78
  def _claude_identity(data, sid, cwd):
@@ -110,7 +118,7 @@ def validate_source(provider, sid, path, cwd, inode=None):
110
118
  if provider == "codex":
111
119
  if not name.startswith("rollout-") or not name.endswith("-" + sid + ".jsonl"):
112
120
  return None
113
- valid = _codex_identity(data, sid, directory)
121
+ valid = _codex_identity(data, sid, directory, resolved, identity)
114
122
  else:
115
123
  valid = name == sid + ".jsonl" and _claude_identity(data, sid, directory)
116
124
  if not valid:
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env bash
2
+ # Exit 0 only for a known `codex exec ... -` prompt that itself requires stdin EOF.
3
+ # Unknown options/subcommands and early exits fail closed into ordinary passthrough.
4
+ set -u
5
+
6
+ seen_exec=0
7
+ for arg in "$@"; do
8
+ case "$arg" in -h|--help|-V|--version) exit 1 ;; esac
9
+ done
10
+ while [ "$#" -gt 0 ]; do
11
+ arg="$1"; shift
12
+ case "$arg" in
13
+ --)
14
+ [ "$seen_exec" = 1 ] && [ "$#" = 1 ] && [ "$1" = - ]
15
+ exit $? ;;
16
+ -)
17
+ [ "$seen_exec" = 1 ] && [ "$#" = 0 ]
18
+ exit $? ;;
19
+ -c|--config|--enable|--disable|--remote|--remote-auth-token-env|-m|--model|--local-provider|\
20
+ -p|--profile|-s|--sandbox|-C|--cd|--add-dir|-a|--ask-for-approval|--thread-source|\
21
+ --cyber-access-program|--output-schema|--color|-o|--output-last-message)
22
+ [ "$#" -gt 0 ] || exit 1
23
+ case "$1" in -*) exit 1 ;; esac
24
+ shift ;;
25
+ -i|--image)
26
+ [ "$#" -gt 0 ] || exit 1
27
+ case "$1" in -*) exit 1 ;; esac
28
+ while [ "$#" -gt 0 ]; do
29
+ case "$1" in -*) break ;; esac
30
+ shift
31
+ done ;;
32
+ --config=*|--enable=*|--disable=*|--remote=*|--remote-auth-token-env=*|--model=*|--local-provider=*|\
33
+ --profile=*|--sandbox=*|--cd=*|--add-dir=*|--ask-for-approval=*|--thread-source=*|\
34
+ --cyber-access-program=*|--output-schema=*|--color=*|--output-last-message=*|--image=*|\
35
+ -c?*|-m?*|-p?*|-s?*|-C?*|-a?*|-o?*|-i?*) ;;
36
+ --strict-config|--oss|--approve-for-me|--dangerously-bypass-approvals-and-sandbox|\
37
+ --dangerously-bypass-hook-trust|--worktree|--skip-git-repo-check|--ephemeral|\
38
+ --ignore-user-config|--ignore-rules|--json|--no-daemon) ;;
39
+ exec|e)
40
+ [ "$seen_exec" = 0 ] || exit 1
41
+ seen_exec=1 ;;
42
+ *) exit 1 ;;
43
+ esac
44
+ done
45
+ exit 1
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env python3
2
+ """Share machine-local Codex MCP fallback credentials and native refresh locks.
3
+
4
+ Codex 0.163.0 opens .credentials.json with O_NOFOLLOW, writes it in place, and
5
+ unlinks it when its final entry is removed. Hardlinks share one live document;
6
+ a reserved tokenless entry keeps the inode alive after native logout. Native
7
+ Codex still owns OAuth refresh and revocation. auth.json is never inspected.
8
+ Credential values never enter output. Keyring items and network endpoints are not accessed.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import sys
17
+ import subprocess
18
+
19
+ sys.dont_write_bytecode = True
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
21
+ try:
22
+ import tomllib
23
+ except ModuleNotFoundError:
24
+ tomllib = None
25
+
26
+ from codex_mcp_oauth_locks import check_lock_dirs, held, join_locks, native_locks
27
+ from codex_mcp_oauth_store import Deferred, commit_links, merge, read_json, save_receipt
28
+ from codex_mcp_oauth_store import SENTINEL, staged_links, validate, write_shared
29
+
30
+ SHARED_DIR = '.codex-mcp-oauth'
31
+ CREDENTIALS = '.credentials.json'
32
+ ACCOUNT_ID = re.compile(r'^acct-\d{2,4}$')
33
+
34
+
35
+ def account_dirs(root, requested=None):
36
+ manifest = read_json(root / 'accounts.json')
37
+ accounts, physical_homes = [], {}
38
+ default_home = (Path.home() / '.codex').resolve()
39
+ for entry in manifest.get('accounts', []):
40
+ name = entry.get('id') if isinstance(entry, dict) else None
41
+ if not isinstance(name, str) or not ACCOUNT_ID.fullmatch(name):
42
+ continue
43
+ path = root / name
44
+ physical = path.resolve()
45
+ if path.is_symlink() and physical != default_home:
46
+ raise Deferred('only the default Codex home can be adopted')
47
+ if not path.is_dir() or path in accounts:
48
+ continue
49
+ if physical in physical_homes:
50
+ raise Deferred('manifest accounts share the same physical Codex home')
51
+ physical_homes[physical] = path
52
+ accounts.append(path)
53
+ if requested is not None and Path(requested).resolve() not in physical_homes:
54
+ raise Deferred('account outside manifest')
55
+ return sorted(accounts)
56
+
57
+
58
+ def check_paths(root, accounts):
59
+ if account_dirs(root) != sorted(accounts):
60
+ raise Deferred('manifest accounts changed during OAuth preparation')
61
+ shared = root / SHARED_DIR
62
+ for path in (root, shared, shared / 'locks', shared / 'credentials.json', shared / 'managed.json'):
63
+ if path.is_symlink():
64
+ raise Deferred('shared state must remain inside pool')
65
+ for account in accounts:
66
+ if (account / CREDENTIALS).is_symlink():
67
+ raise Deferred('credential symlink is unsupported by native Codex')
68
+ check_lock_dirs(accounts, shared / 'locks')
69
+ check_backends(accounts)
70
+
71
+
72
+ def shared_document(accounts, shared):
73
+ authority, receipt_path = shared / 'credentials.json', shared / 'managed.json'
74
+ receipt = read_json(receipt_path)
75
+ managed = receipt.get('accounts', [])
76
+ if not isinstance(managed, list) or not all(isinstance(name, str) for name in managed):
77
+ raise Deferred('invalid sharing receipt')
78
+ retired = bool(managed) and (not authority.exists() or any(
79
+ account.name in managed and not (account / CREDENTIALS).exists() for account in accounts))
80
+ canonical = validate(read_json(authority)) if not retired else {}
81
+ documents = [canonical]
82
+ for account in accounts:
83
+ path = account / CREDENTIALS
84
+ if not path.exists() or retired and account.name in managed:
85
+ continue
86
+ if authority.exists() and os.path.samefile(path, authority):
87
+ continue
88
+ private = validate(read_json(path))
89
+ private_grants = {key: value for key, value in private.items() if key != SENTINEL}
90
+ current_grants = {key: value for key, value in canonical.items() if key != SENTINEL}
91
+ if (authority.exists() or managed) and private_grants and private_grants != current_grants:
92
+ raise Deferred('private credential document conflicts with shared authority')
93
+ documents.append(private)
94
+ return merge(documents)
95
+
96
+
97
+ def reconcile(root, accounts):
98
+ shared = root / SHARED_DIR
99
+ shared_document(accounts, shared) # Reject conflicts before creating any migration state.
100
+ shared.mkdir(mode=0o700, exist_ok=True)
101
+ os.chmod(shared, 0o700)
102
+ locks = shared / 'locks'
103
+ # Existing credential owners go first when consolidating native lock files.
104
+ accounts = sorted(accounts, key=lambda item: not (item / CREDENTIALS).exists())
105
+ with native_locks(accounts, locks) as locked_paths:
106
+ doc = shared_document(accounts, shared)
107
+ authority = shared / 'credentials.json'
108
+ if not authority.exists():
109
+ owner = next((item / CREDENTIALS for item in accounts if (item / CREDENTIALS).exists()), None)
110
+ if owner is not None:
111
+ os.link(owner, authority)
112
+ if not authority.exists():
113
+ write_shared(authority, doc)
114
+ with staged_links(authority, accounts, locks) as pending:
115
+ join_locks(accounts, locks, locked_paths)
116
+ if read_json(authority) != doc:
117
+ write_shared(authority, doc)
118
+ else:
119
+ os.chmod(authority, 0o600)
120
+ commit_links(pending)
121
+ save_receipt(shared / 'managed.json', accounts)
122
+ return True
123
+
124
+
125
+ def ensure(root, account_dir=None, initialize=False, strict=False):
126
+ if os.environ.get('CODEX_MULTIACC_MCP_OAUTH', '1').lower() in ('0', 'false', 'off', 'no'):
127
+ return False
128
+ root = Path(os.path.abspath(root))
129
+ try:
130
+ if root.is_symlink():
131
+ raise Deferred('pool root must not be a symlink')
132
+ root = root.resolve()
133
+ accounts = account_dirs(root, account_dir)
134
+ if not accounts:
135
+ return False
136
+ if not initialize and not (root / SHARED_DIR).exists() and not any(
137
+ (item / CREDENTIALS).exists() for item in accounts):
138
+ return False
139
+ check_paths(root, accounts)
140
+ with held(root / '.codex-mcp-oauth.lock'):
141
+ check_paths(root, accounts)
142
+ return reconcile(root, accounts)
143
+ except Deferred:
144
+ if strict:
145
+ raise
146
+ return False
147
+ except (OSError, ValueError, TypeError) as exc:
148
+ if strict:
149
+ raise Deferred('invalid or inaccessible MCP OAuth storage') from exc
150
+ return False
151
+
152
+
153
+ def check_backends(accounts):
154
+ for account in accounts:
155
+ config = account / 'config.toml'
156
+ if not config.exists():
157
+ continue
158
+ if tomllib is None:
159
+ raise Deferred('MCP OAuth sharing requires Python 3.11 or newer')
160
+ with config.open('rb') as stream:
161
+ doc = tomllib.load(stream)
162
+ features = doc.get('features', {})
163
+ if not isinstance(features, dict):
164
+ raise Deferred('invalid Codex feature configuration')
165
+ if doc.get('mcp_oauth_credentials_store', 'auto') != 'file' and features.get('secret_auth_storage', False):
166
+ raise Deferred('encrypted per-account MCP storage cannot be shared')
167
+
168
+
169
+ def supported_version(binary):
170
+ try:
171
+ result = subprocess.run([binary, '--version'], capture_output=True, text=True, timeout=3, check=False)
172
+ except (OSError, subprocess.TimeoutExpired) as exc:
173
+ raise Deferred('native Codex version could not be verified') from exc
174
+ match = re.fullmatch(r'codex-cli (\d+)\.(\d+)\.(\d+)\s*', result.stdout)
175
+ if result.returncode or not match or tuple(map(int, match.groups())) < (0, 163, 0):
176
+ raise Deferred('MCP OAuth sharing requires native Codex 0.163.0 or newer')
177
+
178
+
179
+ def main():
180
+ parser = argparse.ArgumentParser(description=__doc__)
181
+ parser.add_argument('command', choices=('ensure', 'apply'))
182
+ parser.add_argument('--root', required=True)
183
+ parser.add_argument('--codex-bin', required=True)
184
+ parser.add_argument('--account-dir')
185
+ parser.add_argument('--init', action='store_true')
186
+ parser.add_argument('--quiet', action='store_true')
187
+ args = parser.parse_args()
188
+ try:
189
+ supported_version(args.codex_bin)
190
+ prepared = ensure(args.root, args.account_dir, args.init, strict=True)
191
+ if not args.quiet:
192
+ print('Codex MCP OAuth storage is shared across this pool.' if prepared
193
+ else 'No MCP OAuth storage prepared (disabled or no saved login).')
194
+ return 0
195
+ except Deferred as exc:
196
+ if not args.quiet:
197
+ print(f'codex-mcp-oauth: {exc}', file=sys.stderr)
198
+ return 1
199
+
200
+
201
+ if __name__ == '__main__':
202
+ from codex_python import require_python311
203
+ require_python311(__file__)
204
+ sys.exit(main())
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # Native Codex MCP credentials and refresh locks stay local to this pool.
3
+ # No Python startup for a pool that has never saved an MCP OAuth login.
4
+ codex_mcp_oauth_ensure() { # root, python, module, optional account directory and native binary
5
+ [ "${CODEX_MULTIACC_MCP_OAUTH:-1}" != "0" ] || return 0
6
+ local root="$1" py="$2" module="$3" account="${4:-}" real="${5:-}" found=0 f
7
+ [ -f "$root/accounts.json" ] && [ -f "$module" ] || return 0
8
+ if [ -d "$root/.codex-mcp-oauth" ]; then
9
+ found=1
10
+ else
11
+ for f in "$root"/acct-*/.credentials.json; do
12
+ if [ -f "$f" ]; then found=1; break; fi
13
+ done
14
+ fi
15
+ [ "$found" = 1 ] || return 0
16
+ if [ -z "$real" ] && type find_real_codex >/dev/null 2>&1; then
17
+ real="$(find_real_codex 2>/dev/null)" || return 0
18
+ fi
19
+ [ -n "$real" ] || return 0
20
+ if [ -n "$account" ]; then
21
+ "$py" "$module" ensure --root "$root" --codex-bin "$real" --account-dir "$account" --quiet \
22
+ >/dev/null 2>&1 </dev/null || true
23
+ else
24
+ "$py" "$module" ensure --root "$root" --codex-bin "$real" --quiet >/dev/null 2>&1 </dev/null || true
25
+ fi
26
+ return 0
27
+ }
28
+
29
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then codex_mcp_oauth_ensure "$@"; fi
@@ -0,0 +1,158 @@
1
+ """Join native Codex OAuth lock domains without bypassing an active lock holder."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import fcntl
6
+ import os
7
+ import re
8
+ import stat
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from codex_mcp_oauth_native import exchange, exchange_supported, external_handles
13
+ from codex_mcp_oauth_store import Deferred
14
+
15
+ LOCK_NAME = re.compile(r'^(?:[a-f0-9]{64}|file-store|secrets-store)\.lock$')
16
+
17
+
18
+ class LockPaths(dict):
19
+ """Native path snapshot plus the descriptors owned by this migration."""
20
+
21
+
22
+ @contextlib.contextmanager
23
+ def held(path):
24
+ fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
25
+ try:
26
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
27
+ raise Deferred('non-regular lock')
28
+ try:
29
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
30
+ except BlockingIOError as exc:
31
+ raise Deferred('native OAuth operation in progress') from exc
32
+ yield fd
33
+ finally:
34
+ os.close(fd)
35
+
36
+
37
+ def check_lock_dirs(accounts, shared):
38
+ for account in accounts:
39
+ path = account / 'mcp-oauth-locks'
40
+ if path.is_symlink():
41
+ if path.resolve() != shared:
42
+ raise Deferred('foreign lock directory link')
43
+ elif path.exists() and not path.is_dir():
44
+ raise Deferred('non-directory native locks')
45
+ if path.exists():
46
+ for item in path.iterdir():
47
+ if item.is_symlink() or not item.is_file() or not LOCK_NAME.fullmatch(item.name):
48
+ raise Deferred('unsupported native lock state')
49
+
50
+
51
+ def retired_directories(shared):
52
+ root = shared.parent / 'retired-locks'
53
+ if root.is_symlink() or root.exists() and not root.is_dir():
54
+ raise Deferred('unsupported retired native locks')
55
+ directories = []
56
+ for directory in root.iterdir() if root.exists() else []:
57
+ if not re.fullmatch(r'acct-\d{2,4}', directory.name):
58
+ raise Deferred('unsupported retired native locks')
59
+ if directory.is_symlink():
60
+ if directory.resolve() != shared:
61
+ raise Deferred('foreign retired lock link')
62
+ continue
63
+ if not directory.is_dir():
64
+ raise Deferred('unsupported retired native locks')
65
+ for path in directory.iterdir():
66
+ if path.is_symlink() or not path.is_file() or not LOCK_NAME.fullmatch(path.name):
67
+ raise Deferred('unsupported retired native locks')
68
+ directories.append(directory)
69
+ return directories
70
+
71
+
72
+ @contextlib.contextmanager
73
+ def native_locks(accounts, shared):
74
+ paths = [account / 'mcp-oauth-locks' for account in accounts] + [shared]
75
+ paths += retired_directories(shared)
76
+ with contextlib.ExitStack() as stack:
77
+ seen = set()
78
+ locked_paths = LockPaths()
79
+ locked_paths.owned_fds = set()
80
+ for directory in paths:
81
+ directory.mkdir(mode=0o700, exist_ok=True)
82
+ file_lock = directory / 'file-store.lock'
83
+ candidates = set(directory.iterdir())
84
+ if directory != shared or file_lock.exists():
85
+ candidates.add(file_lock)
86
+ candidates = sorted(candidates, key=lambda item: item.name)
87
+ locked_paths[directory] = candidates
88
+ for path in candidates:
89
+ if path.exists():
90
+ inode = (path.stat().st_dev, path.stat().st_ino)
91
+ if inode in seen:
92
+ continue
93
+ locked_paths.owned_fds.add(stack.enter_context(held(path)))
94
+ seen.add((path.stat().st_dev, path.stat().st_ino))
95
+ yield locked_paths
96
+
97
+
98
+ def prepare_cutover(directories, shared, locked_paths):
99
+ exchange_supported()
100
+ paths = [path for values in locked_paths.values() for path in values]
101
+ if external_handles(paths, locked_paths.owned_fds):
102
+ raise Deferred('native OAuth lock handles are still open')
103
+ retired = shared.parent / 'retired-locks'
104
+ retired.mkdir(mode=0o700, exist_ok=True)
105
+ for directory in directories:
106
+ if set(directory.iterdir()) != set(locked_paths[directory]):
107
+ raise Deferred('native OAuth lock state changed during migration')
108
+ for source in locked_paths[directory]:
109
+ target = shared / source.name
110
+ if not target.exists():
111
+ os.link(source, target)
112
+ pending = retired / directory.parent.name
113
+ if pending.is_symlink() and pending.resolve() == shared:
114
+ pending.unlink()
115
+ pending.symlink_to(shared, target_is_directory=True)
116
+ return retired
117
+
118
+
119
+ def drain_retired(shared, owned_fds):
120
+ directories = retired_directories(shared)
121
+ for directory in directories:
122
+ for path in directory.iterdir():
123
+ try:
124
+ os.link(path, shared / path.name)
125
+ except FileExistsError:
126
+ pass
127
+ discarded = [path for directory in directories for path in directory.iterdir()
128
+ if not (shared / path.name).exists() or not os.path.samefile(path, shared / path.name)]
129
+ # Never release either lock domain while a pre-cutover native waiter can still use the old inode.
130
+ # Native contenders normally time out within 60s; no unsafe fixed deadline admits a suspended fd.
131
+ while discarded:
132
+ try:
133
+ if not external_handles(discarded, owned_fds):
134
+ break
135
+ except Deferred:
136
+ pass
137
+ time.sleep(0.05)
138
+ for directory in directories:
139
+ for path in directory.iterdir():
140
+ path.unlink()
141
+ directory.rmdir()
142
+
143
+
144
+ def join_locks(accounts, shared, locked_paths):
145
+ """Preserve owner inodes; atomically cut over and drain retired native waiters."""
146
+ directories = [account / 'mcp-oauth-locks' for account in accounts
147
+ if not (account / 'mcp-oauth-locks').is_symlink()]
148
+ if not directories:
149
+ if retired_directories(shared):
150
+ exchange_supported()
151
+ drain_retired(shared, locked_paths.owned_fds)
152
+ return
153
+ retired = prepare_cutover(directories, shared, locked_paths)
154
+ try:
155
+ for directory in directories:
156
+ exchange(directory, retired / directory.parent.name)
157
+ finally:
158
+ drain_retired(shared, locked_paths.owned_fds)