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
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Sharing boundaries and recovery use synthetic machine-local pools only."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+ import subprocess
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
13
+ import codex_mcp_oauth as M
14
+ from codex_mcp_oauth_store import SENTINEL
15
+
16
+ ENTRY = {'server_name': 'demo', 'server_url': 'https://example.invalid/mcp',
17
+ 'client_id': 'fake-client', 'access_token': 'fake-access', 'refresh_token': 'fake-refresh'}
18
+
19
+
20
+ class SafetyTests(unittest.TestCase):
21
+ def setUp(self):
22
+ self.temp = tempfile.TemporaryDirectory()
23
+ self.addCleanup(self.temp.cleanup)
24
+ self.root = Path(self.temp.name)
25
+ self.account = self.root / 'acct-01'
26
+ self.account.mkdir()
27
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}]}))
28
+ self.source = self.account / '.credentials.json'
29
+ self.source.write_text(json.dumps({'demo|1234': ENTRY}))
30
+ self.shared = self.root / M.SHARED_DIR
31
+
32
+ def test_nonmanifest_and_symlink_accounts_are_never_touched(self):
33
+ outside = self.root / 'unmanaged'
34
+ outside.mkdir()
35
+ self.assertFalse(M.ensure(self.root, outside))
36
+ self.assertFalse(self.shared.exists())
37
+ self.source.unlink()
38
+ self.account.rmdir()
39
+ self.account.symlink_to(outside, target_is_directory=True)
40
+ self.assertFalse(M.ensure(self.root, self.account, initialize=True))
41
+ self.assertEqual(list(outside.iterdir()), [])
42
+
43
+ def test_symlink_credentials_and_shared_state_are_refused(self):
44
+ other = self.root / 'private.json'
45
+ self.source.rename(other)
46
+ before = other.read_bytes()
47
+ self.source.symlink_to(other)
48
+ self.assertFalse(M.ensure(self.root))
49
+ self.assertEqual(other.read_bytes(), before)
50
+ self.source.unlink()
51
+ other.rename(self.source)
52
+ outside = self.root / 'outside'
53
+ outside.mkdir()
54
+ self.shared.symlink_to(outside, target_is_directory=True)
55
+ self.assertFalse(M.ensure(self.root))
56
+ self.assertEqual(list(outside.iterdir()), [])
57
+
58
+ def test_shared_canonical_deletion_never_reimports_old_link(self):
59
+ self.assertTrue(M.ensure(self.root))
60
+ (self.shared / 'credentials.json').unlink()
61
+ self.assertTrue(M.ensure(self.root))
62
+ self.assertEqual(set(json.loads(self.source.read_text())), {SENTINEL})
63
+ self.assertTrue(M.ensure(self.root))
64
+ self.assertEqual(set(json.loads(self.source.read_text())), {SENTINEL})
65
+
66
+ def test_removed_account_link_clears_shared_document(self):
67
+ self.assertTrue(M.ensure(self.root))
68
+ self.source.unlink()
69
+ self.assertTrue(M.ensure(self.root))
70
+ self.assertEqual(set(json.loads(self.source.read_text())), {SENTINEL})
71
+
72
+ def test_foreign_locks_and_malformed_documents_are_refused(self):
73
+ foreign = self.root / 'foreign-locks'
74
+ foreign.mkdir()
75
+ locks = self.account / 'mcp-oauth-locks'
76
+ locks.symlink_to(foreign, target_is_directory=True)
77
+ self.assertFalse(M.ensure(self.root))
78
+ self.assertEqual(list(foreign.iterdir()), [])
79
+ locks.unlink()
80
+ for doc in ([], {'bad': {'access_token': 'secret-do-not-print'}}, {'bad': dict(ENTRY, expires_at='bad')}):
81
+ self.source.write_text(json.dumps(doc))
82
+ self.assertFalse(M.ensure(self.root))
83
+ self.assertFalse(self.shared.exists())
84
+
85
+ def test_encrypted_backend_is_refused_unless_explicit_file_store(self):
86
+ config = self.account / 'config.toml'
87
+ config.write_text('[features]\nsecret_auth_storage = true\n')
88
+ self.assertFalse(M.ensure(self.root))
89
+ self.assertFalse(self.shared.exists())
90
+ config.write_text('mcp_oauth_credentials_store = "file"\n[features]\nsecret_auth_storage = true\n')
91
+ self.assertTrue(M.ensure(self.root))
92
+
93
+ def test_cli_refusal_reports_only_static_reason(self):
94
+ binary = self.root / 'codex-fake'
95
+ binary.write_text('#!/bin/sh\necho "codex-cli 0.163.0"\n')
96
+ binary.chmod(0o700)
97
+ self.source.write_text('{"secret-do-not-print": "private-value-do-not-print"}')
98
+ command = [sys.executable, M.__file__, 'ensure', '--root', str(self.root), '--codex-bin', str(binary)]
99
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
100
+ self.assertEqual(result.returncode, 1)
101
+ self.assertIn('invalid credential entry', result.stderr)
102
+ self.assertNotIn('private-value', result.stdout + result.stderr)
103
+ self.assertNotIn('secret-do-not-print', result.stdout + result.stderr)
104
+ quiet = subprocess.run(command + ['--quiet'], capture_output=True, text=True, check=False)
105
+ self.assertEqual(quiet.returncode, 1)
106
+ self.assertEqual(quiet.stdout + quiet.stderr, '')
107
+
108
+ def test_failed_link_staging_preserves_original_grant_and_lock_directory(self):
109
+ other = self.root / 'acct-02'
110
+ other.mkdir()
111
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}, {'id': 'acct-02'}]}))
112
+ before = self.source.read_bytes()
113
+ real_link = os.link
114
+
115
+ def reject_destination(source, destination):
116
+ if Path(destination).parent == other.resolve():
117
+ raise OSError('synthetic destination failure')
118
+ return real_link(source, destination)
119
+
120
+ with patch('codex_mcp_oauth_store.os.link', side_effect=reject_destination):
121
+ self.assertFalse(M.ensure(self.root))
122
+ self.assertEqual(self.source.read_bytes(), before)
123
+ self.assertFalse((other / '.credentials.json').exists())
124
+ self.assertFalse((self.account / 'mcp-oauth-locks').is_symlink())
125
+ self.assertEqual(list(other.glob('.codex-mcp-oauth*')), [])
126
+ self.assertTrue(M.ensure(self.root))
127
+
128
+
129
+ if __name__ == '__main__':
130
+ unittest.main()
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise Codex OAuth launch hooks with native-shaped, entirely synthetic state."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import unittest
13
+
14
+ REPO = Path(__file__).resolve().parents[1]
15
+ SHIM = REPO / 'bin/codex'
16
+ CLI = REPO / 'bin/codex-accounts'
17
+ ENTRY = {'server_name': 'motionsites', 'server_url': 'https://example.invalid/mcp',
18
+ 'client_id': 'fake-client', 'access_token': 'fake-mcp-access', 'refresh_token': 'fake-mcp-refresh',
19
+ 'expires_at': 2000000000000, 'scopes': []}
20
+ DOCUMENT = {'motionsites|0000000000000001': ENTRY}
21
+ REAL = '''#!/bin/sh
22
+ if [ "${1:-}" = --version ]; then
23
+ echo "codex-cli ${TEST_CODEX_VERSION:-0.163.0}"
24
+ exit 0
25
+ fi
26
+ printf 'exec %s\n' "${CODEX_HOME##*/}" >> "$EVENT_LOG"
27
+ if [ "$CODEX_HOME/.credentials.json" -ef "$CODEX_ACCOUNTS_ROOT/.codex-mcp-oauth/credentials.json" ]; then
28
+ echo shared
29
+ else
30
+ echo private
31
+ fi
32
+ if [ "${TEST_RETRY_ONCE:-0}" = 1 ] && [ ! -e "$RETRY_MARK" ]; then
33
+ : > "$RETRY_MARK"
34
+ echo 'usage limit exceeded' >&2
35
+ exit 1
36
+ fi
37
+ '''
38
+ PYTHON = '''#!/bin/sh
39
+ printf 'python %s\n' "${1##*/}" >> "$EVENT_LOG"
40
+ exec "$REAL_PYTHON" "$@"
41
+ '''
42
+
43
+
44
+ class CodexMcpOauthShimTests(unittest.TestCase):
45
+ def setUp(self):
46
+ work = tempfile.TemporaryDirectory(prefix='codex-mcp-oauth-shim-')
47
+ self.addCleanup(work.cleanup)
48
+ self.work = Path(work.name)
49
+ self.pool, self.home, self.realbin = [self.work / name for name in ('pool', 'home', 'realbin')]
50
+ for directory in (self.pool, self.home, self.realbin):
51
+ directory.mkdir()
52
+ self.accounts = [self.pool / name for name in ('acct-01', 'acct-02')]
53
+ manifest = {'version': 1, 'server': 'none', 'accounts': [{'id': p.name} for p in self.accounts]}
54
+ (self.pool / 'accounts.json').write_text(json.dumps(manifest))
55
+ for account in self.accounts:
56
+ account.mkdir()
57
+ (account / 'auth.json').write_text('{"tokens":{"access_token":"fake-codex-access"}}\n')
58
+ (account / 'config.toml').write_text('# account settings\nmodel = "kept"\n')
59
+ (account / 'limits.json').write_text(json.dumps({
60
+ 'fetched_at': int(time.time()), 'weekly_percent': 10, 'session_percent': 10, 'max_percent': 10}))
61
+ self.credential = self.accounts[0] / '.credentials.json'
62
+ self.credential.write_text(json.dumps(DOCUMENT))
63
+ self.events = self.work / 'events.log'
64
+ for name, script in (('codex', REAL), ('python-log', PYTHON)):
65
+ executable = self.realbin / name
66
+ executable.write_text(script)
67
+ executable.chmod(0o755)
68
+ self.env = {
69
+ 'PATH': f'{self.realbin}:/usr/bin:/bin:/usr/sbin:/sbin', 'HOME': str(self.home), 'USER': 'tester',
70
+ 'LC_ALL': 'C', 'CODEX_ACCOUNTS_ROOT': str(self.pool), 'EVENT_LOG': str(self.events),
71
+ 'CLAUDE_MULTIACC_PYTHON': str(self.realbin / 'python-log'), 'REAL_PYTHON': sys.executable,
72
+ 'CODEX_MULTIACC_AUTORESUME': '0', 'CODEX_MULTIACC_CLIENT_LIMITS': '0',
73
+ 'CODEX_MULTIACC_PATH_PROBE': '0', 'CODEX_MULTIACC_MODEL_CATALOG': '0',
74
+ 'CLAUDE_MULTIACC_MODEL_CATALOG': '0', 'CODEX_MULTIACC_MCP': '0',
75
+ 'RETRY_MARK': str(self.work / 'retried'), 'PYTHONDONTWRITEBYTECODE': '1',
76
+ }
77
+
78
+ def run_command(self, argv, **environment):
79
+ self.events.write_text('')
80
+ result = subprocess.run([str(item) for item in argv], env={**self.env, **environment},
81
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=60)
82
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
83
+ self.assertNotIn('fake-mcp-access', result.stdout + result.stderr)
84
+ self.assertNotIn('fake-mcp-refresh', result.stdout + result.stderr)
85
+ return result, self.events.read_text().splitlines()
86
+
87
+ def test_pinned_selected_and_nested_clients_receive_shared_state_before_exec(self):
88
+ modes = ({'CODEX_ACCOUNT': 'acct-02'}, {},
89
+ {'CODEX_HOME': str(self.accounts[1]), 'CODEX_SHIM_ACTIVE': '1'})
90
+ original_auth = [(account / 'auth.json').read_bytes() for account in self.accounts]
91
+ for environment in modes:
92
+ with self.subTest(environment=environment):
93
+ result, events = self.run_command([SHIM, '--help'], **environment)
94
+ self.assertEqual(result.stdout.strip(), 'shared')
95
+ self.assertEqual(events[0], 'python codex_mcp_oauth.py')
96
+ self.assertTrue(events[-1].startswith('exec acct-'))
97
+ authority = self.pool / '.codex-mcp-oauth/credentials.json'
98
+ for account, before in zip(self.accounts, original_auth):
99
+ self.assertTrue(os.path.samefile(account / '.credentials.json', authority))
100
+ self.assertEqual((account / 'mcp-oauth-locks').resolve(), (authority.parent / 'locks').resolve())
101
+ self.assertEqual((account / 'auth.json').read_bytes(), before)
102
+
103
+ def test_retry_prepares_the_replacement_account_before_running_it(self):
104
+ result, events = self.run_command([SHIM, 'exec', 'test prompt'], TEST_RETRY_ONCE='1')
105
+ oauth_and_exec = [line for line in events if line == 'python codex_mcp_oauth.py' or line.startswith('exec ')]
106
+ self.assertEqual(len(oauth_and_exec), 4, events)
107
+ self.assertEqual(oauth_and_exec[::2], ['python codex_mcp_oauth.py'] * 2)
108
+ self.assertNotEqual(oauth_and_exec[1], oauth_and_exec[3])
109
+ self.assertEqual(result.stdout.strip(), 'shared')
110
+
111
+ def test_empty_pool_kill_switch_and_missing_python_preserve_launch(self):
112
+ self.credential.unlink()
113
+ result, events = self.run_command([SHIM, '--help'], CODEX_ACCOUNT='acct-02')
114
+ self.assertEqual((result.stdout.strip(), events), ('private', ['exec acct-02']))
115
+ self.assertFalse((self.pool / '.codex-mcp-oauth').exists())
116
+ self.credential.write_text(json.dumps(DOCUMENT))
117
+ for environment in ({'CODEX_MULTIACC_MCP_OAUTH': '0'}, {'CODEX_MULTIACC_DISABLE': '1',
118
+ 'CODEX_HOME': str(self.accounts[1])},
119
+ {'CLAUDE_MULTIACC_PYTHON': '/nonexistent/python3'}):
120
+ with self.subTest(environment=environment):
121
+ result, events = self.run_command([SHIM, '--help'], CODEX_ACCOUNT='acct-02', **environment)
122
+ self.assertEqual((result.stdout.strip(), events), ('private', ['exec acct-02']))
123
+ self.assertFalse((self.accounts[1] / '.credentials.json').exists())
124
+ broken = self.work / 'broken-install'
125
+ (broken / 'bin').mkdir(parents=True)
126
+ (broken / 'lib').mkdir()
127
+ shim = broken / 'bin/codex'
128
+ shim.write_bytes(SHIM.read_bytes())
129
+ shim.chmod(0o755)
130
+ (broken / 'lib/codex_mcp_oauth.sh').write_text('if then invalid shell syntax\n')
131
+ result, events = self.run_command([shim, '--help'], CODEX_ACCOUNT='acct-02')
132
+ self.assertEqual((result.stdout.strip(), events), ('private', ['exec acct-02']))
133
+
134
+ def test_nested_foreign_and_retired_homes_cannot_initialize_pool_sharing(self):
135
+ for home in (self.work / 'foreign', self.pool / 'acct-99'):
136
+ with self.subTest(home=home):
137
+ home.mkdir()
138
+ path = home / '.credentials.json'
139
+ path.write_text('{"outside":"untouched"}\n')
140
+ before = path.read_bytes()
141
+ result, _events = self.run_command([SHIM, '--help'], CODEX_HOME=str(home), CODEX_SHIM_ACTIVE='1')
142
+ self.assertEqual(result.stdout.strip(), 'private')
143
+ self.assertEqual(path.read_bytes(), before)
144
+ self.assertFalse((self.pool / '.codex-mcp-oauth').exists())
145
+ self.assertEqual(json.loads(self.credential.read_text()), DOCUMENT)
146
+
147
+ def test_auth_sync_dispatches_and_keeps_cli_arguments(self):
148
+ result, events = self.run_command([CLI, 'mcp', 'auth-sync', '--quiet'])
149
+ self.assertEqual(result.stdout, '')
150
+ self.assertIn('python codex_mcp_oauth.py', events)
151
+ self.assertTrue(os.path.samefile(self.credential, self.accounts[1] / '.credentials.json'))
152
+ self.assertTrue((self.accounts[1] / 'mcp-oauth-locks').is_symlink())
153
+
154
+ def test_account_seeding_joins_existing_oauth_state(self):
155
+ command = ['bash', '-c', '. "$1"; seed_account_dir "$2"', 'seed',
156
+ REPO / 'lib/common.sh', self.accounts[1]]
157
+ _result, events = self.run_command(command, MULTIACC_PROVIDER='codex')
158
+ self.assertIn('python codex_mcp_oauth.py', events)
159
+ self.assertTrue(os.path.samefile(self.credential, self.accounts[1] / '.credentials.json'))
160
+ self.assertEqual((self.accounts[1] / 'config.toml').read_text(), '# account settings\nmodel = "kept"\n')
161
+
162
+ def test_pool_backstop_joins_every_manifest_account(self):
163
+ command = ['bash', '-c', '. "$1"; codex_mcp_oauth_ensure "$2" "$3" "$4" "" "$5"', 'backstop',
164
+ REPO / 'lib/codex_mcp_oauth.sh', self.pool, self.realbin / 'python-log',
165
+ REPO / 'lib/codex_mcp_oauth.py', self.realbin / 'codex']
166
+ _result, events = self.run_command(command)
167
+ self.assertEqual(events, ['python codex_mcp_oauth.py'])
168
+ self.assertTrue(os.path.samefile(self.credential, self.accounts[1] / '.credentials.json'))
169
+ # A subsequent pass must accept already linked lock dirs, including /var aliases on macOS.
170
+ newcomer = self.pool / 'acct-03'
171
+ newcomer.mkdir()
172
+ manifest_path = self.pool / 'accounts.json'
173
+ manifest = json.loads(manifest_path.read_text())
174
+ manifest['accounts'].append({'id': newcomer.name})
175
+ manifest_path.write_text(json.dumps(manifest))
176
+ self.run_command(command)
177
+ self.assertTrue(os.path.samefile(self.credential, newcomer / '.credentials.json'))
178
+
179
+ def test_older_native_client_preserves_private_credentials(self):
180
+ result, events = self.run_command([SHIM, '--help'], CODEX_ACCOUNT='acct-02', TEST_CODEX_VERSION='0.162.0')
181
+ self.assertEqual(result.stdout.strip(), 'private')
182
+ self.assertEqual(events, ['python codex_mcp_oauth.py', 'exec acct-02'])
183
+ self.assertFalse((self.pool / '.codex-mcp-oauth').exists())
184
+ self.assertEqual(json.loads(self.credential.read_text()), DOCUMENT)
185
+
186
+
187
+ if __name__ == '__main__':
188
+ unittest.main()
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Buffered Codex retry owns and terminates only its active native child."""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ import unittest
13
+
14
+ import test_codex_exec_stdin as pipe_fixture
15
+
16
+ REPO = Path(__file__).resolve().parents[1]
17
+ HELPER = REPO / 'lib/codex_retry_process.sh'
18
+ RUNNER = '''source "$1"
19
+ exec 3< "$2/input"
20
+ fixture_root="$2"
21
+ trap 'printf cleaned > "$fixture_root/exit-cleanup"' EXIT
22
+ trap 'printf restored > "$fixture_root/restored-trap"' TERM
23
+ CODEX_HOME="$2/fake-profile" codex_retry_run "$3" "$2/native.py" "$2" "$4"
24
+ rc=$?
25
+ [ "$4" != restore ] || kill -TERM $$
26
+ exit "$rc"
27
+ '''
28
+ NATIVE = '''import json, os, pathlib, signal, sys, time
29
+ root = pathlib.Path(sys.argv[1])
30
+ mode = sys.argv[2]
31
+ def stop(number, frame):
32
+ (root / 'stopped').write_text(str(number))
33
+ sys.exit(0)
34
+ for number in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
35
+ signal.signal(number, stop)
36
+ data = sys.stdin.buffer.read()
37
+ (root / 'received').write_bytes(data)
38
+ (root / 'profile').write_text(os.environ.get('CODEX_HOME', ''))
39
+ (root / 'native.pid').write_text(str(os.getpid()))
40
+ if mode == 'wait':
41
+ while True:
42
+ time.sleep(0.05)
43
+ print(json.dumps({'mode': mode}))
44
+ print('native stderr', file=sys.stderr)
45
+ sys.exit(7 if mode == 'fail' else 0)
46
+ '''
47
+
48
+
49
+ class CodexRetryProcessTests(unittest.TestCase):
50
+ def launch(self, mode):
51
+ temporary = tempfile.TemporaryDirectory(prefix='multiacc-retry-process-')
52
+ self.addCleanup(temporary.cleanup)
53
+ root = Path(temporary.name)
54
+ (root / 'input').write_bytes(b'finite stdin\n\x00\xff')
55
+ (root / 'native.py').write_text(NATIVE)
56
+ process = subprocess.Popen(
57
+ ['bash', '-c', RUNNER, 'retry-test', str(HELPER), str(root), sys.executable, mode],
58
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
59
+ self.addCleanup(self.cleanup_process, process, root)
60
+ deadline = time.monotonic() + 5
61
+ while not (root / 'native.pid').exists() and process.poll() is None and time.monotonic() < deadline:
62
+ time.sleep(0.02)
63
+ self.assertTrue((root / 'native.pid').exists(), 'owned native child did not launch')
64
+ return process, root
65
+
66
+ def cleanup_process(self, process, root):
67
+ # Only this fixture's owned native PID; ordinary assertions verify it was
68
+ # already reaped. A regression must not leave a sleeping fake behind.
69
+ if process.poll() is None:
70
+ process.terminate()
71
+ pid_file = root / 'native.pid'
72
+ if pid_file.exists():
73
+ try:
74
+ os.kill(int(pid_file.read_text()), signal.SIGTERM)
75
+ except ProcessLookupError:
76
+ pass
77
+ try:
78
+ process.communicate(timeout=3)
79
+ except subprocess.TimeoutExpired:
80
+ process.kill()
81
+ process.communicate(timeout=3)
82
+
83
+ def test_termination_signals_reach_and_reap_only_the_owned_native_child(self):
84
+ for number in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
85
+ with self.subTest(signal=number):
86
+ process, root = self.launch('wait')
87
+ process.send_signal(number)
88
+ process.communicate(timeout=3)
89
+ self.assertEqual(process.returncode, 128 + number)
90
+ self.assertEqual(int((root / 'stopped').read_text()), number)
91
+ with self.assertRaises(ProcessLookupError):
92
+ os.kill(int((root / 'native.pid').read_text()), 0)
93
+ self.assertEqual((root / 'exit-cleanup').read_text(), 'cleaned')
94
+
95
+ def test_success_failure_and_prior_traps_are_preserved(self):
96
+ for mode, expected in [('success', 0), ('fail', 7), ('restore', 0)]:
97
+ with self.subTest(mode=mode):
98
+ process, root = self.launch(mode)
99
+ stdout, stderr = process.communicate(timeout=3)
100
+ self.assertEqual(process.returncode, expected)
101
+ self.assertEqual(json.loads(stdout), {'mode': mode})
102
+ self.assertEqual(stderr, b'native stderr\n')
103
+ self.assertEqual((root / 'received').read_bytes(), (root / 'input').read_bytes())
104
+ self.assertEqual((root / 'profile').read_text(), str(root / 'fake-profile'))
105
+ self.assertEqual((root / 'exit-cleanup').read_text(), 'cleaned')
106
+ if mode == 'restore':
107
+ self.assertEqual((root / 'restored-trap').read_text(), 'restored')
108
+
109
+ def test_actual_shim_pipe_cancellation_reaps_native_without_retry_or_scratch(self):
110
+ fixture = pipe_fixture.CodexExecStdinTests()
111
+ fixture.setUp()
112
+ self.addCleanup(fixture.doCleanups)
113
+ root = fixture.root
114
+ native = NATIVE.replace('root = pathlib.Path(sys.argv[1])',
115
+ "root = pathlib.Path(os.environ['FAKE_ROOT'])\n"
116
+ "with (root / 'attempts').open('a') as handle: handle.write('attempt\\n')")
117
+ native = native.replace('mode = sys.argv[2]', "mode = 'wait'")
118
+ (root / 'bin/codex').write_text(f'#!{sys.executable}\n' + native)
119
+ process = subprocess.Popen([str(REPO / 'bin/codex'), *fixture.args], env=fixture.env,
120
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
121
+ self.addCleanup(self.cleanup_process, process, root)
122
+ process.stdin.write(pipe_fixture.PROMPT)
123
+ process.stdin.close()
124
+ process.stdin = None
125
+ deadline = time.monotonic() + 5
126
+ while not (root / 'native.pid').exists() and process.poll() is None and time.monotonic() < deadline:
127
+ time.sleep(0.02)
128
+ self.assertTrue((root / 'native.pid').exists(), 'shim did not launch native child')
129
+ process.send_signal(signal.SIGTERM)
130
+ process.communicate(timeout=3)
131
+ self.assertEqual(process.returncode, 143)
132
+ self.assertEqual(int((root / 'stopped').read_text()), signal.SIGTERM)
133
+ with self.assertRaises(ProcessLookupError):
134
+ os.kill(int((root / 'native.pid').read_text()), 0)
135
+ self.assertEqual((root / 'attempts').read_text(), 'attempt\n')
136
+ self.assertEqual((root / 'received').read_bytes(), pipe_fixture.PROMPT)
137
+ self.assertEqual(list(fixture.pool.glob('tmp/shim.*')), [])
138
+ self.assertNotIn('retry from=', (fixture.pool / 'selection.log').read_text())
139
+ self.assertFalse((fixture.pool / 'acct-01/.limited').exists())
140
+
141
+
142
+ if __name__ == '__main__':
143
+ unittest.main()