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,178 @@
1
+ #!/usr/bin/env python3
2
+ """Exercise finite Node pipe prompts against the real shim and fake native Codex."""
3
+
4
+ import base64
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ import unittest
14
+
15
+ REPO = Path(__file__).resolve().parents[1]
16
+ SHIM = os.environ.get('MULTIACC_TEST_SHIM', str(REPO / 'bin/codex'))
17
+ NODE = shutil.which('node')
18
+ PROMPT = b'Original image brief\nUnicode: \xf0\x9f\x8c\xb1\nBinary fidelity: \x00\xff\n'
19
+ FAKE = '''import base64, json, os, pathlib, sys
20
+ root = pathlib.Path(os.environ['FAKE_ROOT'])
21
+ account = pathlib.Path(os.environ['CODEX_HOME']).name
22
+ args = sys.argv[1:]
23
+ data = sys.stdin.buffer.read() if args[-1:] == ['-'] and os.environ.get('FAKE_READ', '1') == '1' else b''
24
+ with (root / 'calls.jsonl').open('a') as handle:
25
+ handle.write(json.dumps({'account': account, 'argv': args,
26
+ 'selected_home': os.environ.get('CODEX_MULTIACC_SELECTED_HOME'),
27
+ 'stdin': base64.b64encode(data).decode()}) + '\\n')
28
+ mode = os.environ.get('FAKE_MODE', 'quota')
29
+ if mode == 'ordinary':
30
+ print('ordinary failure', file=sys.stderr)
31
+ sys.exit(7)
32
+ if mode == 'all-quota' or (account == 'acct-01' and mode in ('quota', 'auth')):
33
+ print(json.dumps({'type': 'turn.failed', 'partial': account}))
34
+ print('ERROR: You’ve hit your usage limit.' if mode != 'auth' else
35
+ 'Error: token expired. Please run codex login again', file=sys.stderr)
36
+ sys.exit(1)
37
+ print(json.dumps({'type': 'turn.completed', 'account': account}))
38
+ '''
39
+ LAUNCH = '''const {spawn} = require('node:child_process');
40
+ const child = spawn(process.env.TEST_SHIM, JSON.parse(process.env.TEST_ARGS),
41
+ {stdio: ['pipe', 'pipe', 'pipe']});
42
+ child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr);
43
+ child.stdin.on('error', error => { if (error.code !== 'EPIPE') throw error; });
44
+ if (process.env.TEST_KEEP_OPEN !== '1') {
45
+ child.stdin.end(Buffer.from(process.env.TEST_INPUT, 'base64'));
46
+ }
47
+ child.on('close', code => { child.stdin.destroy(); process.exitCode = code; });
48
+ '''
49
+
50
+
51
+ @unittest.skipUnless(NODE, 'Node is required for the actual child-process transport')
52
+ class CodexExecStdinTests(unittest.TestCase):
53
+ def setUp(self):
54
+ self.tmp = tempfile.TemporaryDirectory(prefix='multiacc-exec-stdin-')
55
+ self.addCleanup(self.tmp.cleanup)
56
+ self.root = Path(self.tmp.name)
57
+ self.pool = self.root / 'pool'
58
+ self.pool.mkdir()
59
+ (self.pool / 'accounts.json').write_text('{"threshold":90,"accounts":[]}')
60
+ (self.pool / '.limits-kick').touch()
61
+ for index in (1, 2):
62
+ account = self.pool / f'acct-{index:02d}'
63
+ account.mkdir()
64
+ (account / 'auth.json').write_text('{"access_token":"fake-offline-only"}')
65
+ (account / 'limits.json').write_text(json.dumps({
66
+ 'fetched_at': int(time.time()), 'weekly_percent': index * 10,
67
+ 'session_percent': 5, 'max_percent': index * 10, 'buckets': []}))
68
+ fakebin = self.root / 'bin'
69
+ fakebin.mkdir()
70
+ fake = fakebin / 'codex'
71
+ fake.write_text(f'#!{sys.executable}\n' + FAKE)
72
+ fake.chmod(0o755)
73
+ self.env = {
74
+ 'HOME': str(self.root), 'PATH': f'{fakebin}:/usr/bin:/bin:/usr/sbin:/sbin',
75
+ 'CODEX_ACCOUNTS_ROOT': str(self.pool), 'CODEX_MULTIACC_HEADROOM_BAND': '0',
76
+ 'CODEX_MULTIACC_MCP': '0', 'CODEX_MULTIACC_AUTO_RESET': '0',
77
+ 'CODEX_MULTIACC_MODEL_CATALOG': '0', 'CLAUDE_MULTIACC_PATH_PROBE': '0',
78
+ 'FAKE_ROOT': str(self.root), 'TEST_SHIM': SHIM,
79
+ 'TEST_INPUT': base64.b64encode(PROMPT).decode(), 'LANG': 'en_US.UTF-8',
80
+ }
81
+ self.args = ['exec', '-C', str(self.root), '--skip-git-repo-check', '--ephemeral',
82
+ '--ignore-user-config', '--json', '--color', 'never', '-s', 'workspace-write',
83
+ '-c', 'model_reasoning_effort=medium', '-c', 'project_doc_max_bytes=0', '-']
84
+
85
+ def run_node(self, args=None, **changes):
86
+ env = {**self.env, 'TEST_ARGS': json.dumps(args or self.args), **changes}
87
+ result = subprocess.run([NODE, '-e', LAUNCH], env=env, capture_output=True, timeout=15)
88
+ calls = self.root / 'calls.jsonl'
89
+ rows = [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
90
+ return result, rows
91
+
92
+ def test_quota_retries_exact_node_image_prompt_with_identical_bytes(self):
93
+ result, calls = self.run_node()
94
+ self.assertEqual(result.returncode, 0, result.stderr)
95
+ self.assertEqual([row['account'] for row in calls], ['acct-01', 'acct-02'])
96
+ self.assertTrue(all(base64.b64decode(row['stdin']) == PROMPT for row in calls))
97
+ self.assertTrue(all(row['argv'] == self.args for row in calls))
98
+ self.assertTrue(all(row['selected_home'] == str(self.pool / row['account']) for row in calls))
99
+ self.assertNotIn(b'turn.failed', result.stdout)
100
+ self.assertNotIn(b'usage limit', result.stderr)
101
+ self.assertIn('error-cooldown', (self.pool / 'acct-01/.limited').read_text())
102
+ self.assertIn('retry from=acct-01 to=acct-02', (self.pool / 'selection.log').read_text())
103
+
104
+ def test_auth_failure_rotates_and_parks_only_rejected_account(self):
105
+ result, calls = self.run_node(FAKE_MODE='auth')
106
+ self.assertEqual(result.returncode, 0, result.stderr)
107
+ self.assertEqual(len(calls), 2)
108
+ self.assertIn('reason=auth-error', (self.pool / 'acct-01/.expired').read_text())
109
+ self.assertFalse((self.pool / 'acct-02/.expired').exists())
110
+
111
+ def test_success_and_ordinary_failure_never_replay(self):
112
+ for mode, expected in [('success', 0), ('ordinary', 7)]:
113
+ with self.subTest(mode=mode):
114
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
115
+ result, calls = self.run_node(FAKE_MODE=mode)
116
+ self.assertEqual(result.returncode, expected, result.stderr)
117
+ self.assertEqual(len(calls), 1)
118
+ self.assertFalse((self.pool / 'acct-01/.limited').exists())
119
+
120
+ def test_two_exhausted_accounts_return_actual_final_failure_once(self):
121
+ result, calls = self.run_node(FAKE_MODE='all-quota')
122
+ self.assertEqual(result.returncode, 1)
123
+ self.assertEqual(len(calls), 2)
124
+ self.assertEqual(result.stderr.count(b'usage limit'), 1)
125
+ self.assertNotIn(b'turn.completed', result.stdout)
126
+ self.assertEqual(json.loads(result.stdout)['partial'], 'acct-02')
127
+
128
+ def test_explicit_pins_and_disable_keep_the_existing_passthrough_contract(self):
129
+ for changes in [{'CODEX_HOME': str(self.pool / 'acct-01')},
130
+ {'CODEX_ACCOUNT': 'acct-01'}, {'CODEX_SHIM_RETRY': '0'}]:
131
+ with self.subTest(changes=changes):
132
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
133
+ result, calls = self.run_node(**changes)
134
+ self.assertEqual(result.returncode, 1, result.stderr)
135
+ self.assertEqual(len(calls), 1)
136
+ self.assertEqual(calls[0]['account'], 'acct-01')
137
+
138
+ def test_nonconsuming_open_pipe_is_not_buffered(self):
139
+ commands = [['exec', 'prompt supplied in argv'], ['exec', '--image', '-'],
140
+ ['exec', '--config', '--json', '-'], ['exec', '--full-auto', '-'],
141
+ ['exec', '--help', '-'], ['exec', '-o', '-'], ['exec', '--future-option', '-']]
142
+ for args in commands:
143
+ with self.subTest(args=args):
144
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
145
+ result, calls = self.run_node(args, TEST_KEEP_OPEN='1', FAKE_MODE='success', FAKE_READ='0')
146
+ self.assertEqual(result.returncode, 0, result.stderr)
147
+ self.assertEqual(len(calls), 1)
148
+
149
+ def test_auto_selection_provenance_does_not_turn_explicit_pins_into_pool_requests(self):
150
+ selected = str(self.pool / 'acct-01')
151
+ scenarios = [({}, selected), ({'CODEX_HOME': selected}, None),
152
+ ({'CODEX_ACCOUNT': 'acct-01', 'CODEX_MULTIACC_SELECTED_HOME': selected}, None),
153
+ ({'CODEX_HOME': str(self.pool / 'acct-02'),
154
+ 'CODEX_MULTIACC_SELECTED_HOME': selected}, selected)]
155
+ for changes, expected in scenarios:
156
+ with self.subTest(changes=changes):
157
+ (self.root / 'calls.jsonl').unlink(missing_ok=True)
158
+ result, calls = self.run_node(FAKE_MODE='success', **changes)
159
+ self.assertEqual(result.returncode, 0, result.stderr)
160
+ self.assertEqual(len(calls), 1)
161
+ self.assertEqual(calls[0]['selected_home'], expected)
162
+
163
+ def test_parser_only_accepts_explicit_prompt_not_option_values_or_early_exits(self):
164
+ parser = ['bash', str(REPO / 'lib/codex_exec_stdin.sh')]
165
+ accepted = [self.args, ['--no-daemon', 'e', '--image', 'one.png', 'two.png', '-'],
166
+ ['exec', '--', '-'], ['--model=gpt-6.1-sol', 'exec', '-']]
167
+ rejected = [['exec', '-o', '-'], ['exec', '--color', '-'], ['exec', '--help', '-'],
168
+ ['exec', '--future-option', '-'], ['exec', 'resume', '-'],
169
+ ['mcp', 'add', 'exec', '-'], ['exec', 'text'], ['exec', '--image', 'one.png'],
170
+ ['exec', '--image', '-'], ['exec', '--config', '--json', '-'], ['exec', '--full-auto', '-']]
171
+ for args in accepted + rejected:
172
+ with self.subTest(args=args):
173
+ result = subprocess.run(parser + args, capture_output=True)
174
+ self.assertEqual(result.returncode, 0 if args in accepted else 1)
175
+
176
+
177
+ if __name__ == '__main__':
178
+ unittest.main()
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env python3
2
+ """Native-layout synthetic tests; no real pool, credentials, keyring, or network."""
3
+ import fcntl
4
+ import json
5
+ import multiprocessing
6
+ import os
7
+ from pathlib import Path
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from unittest.mock import patch
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
14
+ import codex_mcp_oauth as M
15
+ from codex_mcp_oauth_store import PLACEHOLDER, SENTINEL
16
+
17
+ KEY = 'motionsites|1234567890abcdef'
18
+ ENTRY = {'server_name': 'motionsites', 'server_url': 'https://example.invalid/mcp',
19
+ 'issuer': 'https://example.invalid', 'client_id': 'fake-client', 'access_token': 'fake-access',
20
+ 'refresh_token': 'fake-refresh', 'expires_at': 1900000000000, 'scopes': ['read']}
21
+
22
+
23
+ def native_update(account, key, entry):
24
+ """Mirror native O_NOFOLLOW, aggregate flock, truncate-in-place, empty unlink."""
25
+ lock = account / 'mcp-oauth-locks' / 'file-store.lock'
26
+ with lock.open('a') as handle:
27
+ fcntl.flock(handle, fcntl.LOCK_EX)
28
+ path = account / '.credentials.json'
29
+ doc = json.loads(path.read_text())
30
+ if entry is None:
31
+ doc.pop(key, None)
32
+ else:
33
+ doc[key] = entry
34
+ if not doc:
35
+ path.unlink()
36
+ return
37
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
38
+ with os.fdopen(fd, 'w') as stream:
39
+ stream.truncate(0)
40
+ json.dump(doc, stream)
41
+
42
+
43
+ class SharedStoreTests(unittest.TestCase):
44
+ def setUp(self):
45
+ self.temp = tempfile.TemporaryDirectory()
46
+ self.addCleanup(self.temp.cleanup)
47
+ self.root = Path(self.temp.name)
48
+ self.accounts = [self.root / 'acct-01', self.root / 'acct-02']
49
+ for path in self.accounts:
50
+ path.mkdir()
51
+ (path / 'auth.json').write_text('subscription-login-must-stay-byte-identical')
52
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': p.name} for p in self.accounts]}))
53
+ self.shared = self.root / M.SHARED_DIR
54
+
55
+ def seed(self, account=0, entry=None):
56
+ path = self.accounts[account] / '.credentials.json'
57
+ path.write_text(json.dumps({KEY: ENTRY if entry is None else entry}))
58
+ return path
59
+
60
+ def test_share_native_refresh_and_last_logout_without_resurrection(self):
61
+ source = self.seed()
62
+ inode = source.stat().st_ino
63
+ self.assertTrue(M.ensure(self.root))
64
+ for account in self.accounts:
65
+ self.assertEqual((account / '.credentials.json').stat().st_ino, inode)
66
+ self.assertEqual((account / 'auth.json').read_text(), 'subscription-login-must-stay-byte-identical')
67
+ native_update(self.accounts[1], KEY, dict(ENTRY, access_token='rotated-fake'))
68
+ self.assertEqual(json.loads(source.read_text())[KEY]['access_token'], 'rotated-fake')
69
+ native_update(self.accounts[1], KEY, None)
70
+ self.assertTrue(M.ensure(self.root))
71
+ for account in self.accounts:
72
+ self.assertEqual(json.loads((account / '.credentials.json').read_text()), {SENTINEL: PLACEHOLDER})
73
+ self.assertEqual(source.stat().st_mode & 0o777, 0o600)
74
+ before = source.stat().st_mtime_ns
75
+ receipt_before = (self.shared / 'managed.json').stat().st_mtime_ns
76
+ self.assertTrue(M.ensure(self.root))
77
+ self.assertEqual(source.stat().st_mtime_ns, before)
78
+ self.assertEqual((self.shared / 'managed.json').stat().st_mtime_ns, receipt_before)
79
+
80
+ def test_empty_and_disabled_pools_have_no_auth_state(self):
81
+ self.assertFalse(M.ensure(self.root))
82
+ self.assertFalse(self.shared.exists())
83
+ self.seed()
84
+ with patch.dict(os.environ, {'CODEX_MULTIACC_MCP_OAUTH': '0'}):
85
+ self.assertFalse(M.ensure(self.root))
86
+ self.assertFalse(self.shared.exists())
87
+ self.assertTrue(M.ensure(self.root, initialize=True))
88
+
89
+ def test_conflicting_grants_and_scoped_credentials_are_untouched(self):
90
+ first = self.seed()
91
+ second = self.seed(1, dict(ENTRY, refresh_token='different-grant'))
92
+ before = [first.read_bytes(), second.read_bytes()]
93
+ self.assertFalse(M.ensure(self.root))
94
+ self.assertEqual([first.read_bytes(), second.read_bytes()], before)
95
+ self.assertFalse(self.shared.exists())
96
+ second.unlink()
97
+ for update in ({'executor_owned': True}, {'server_name': 'ema-idp:private'}):
98
+ self.seed(entry=dict(ENTRY, **update))
99
+ self.assertFalse(M.ensure(self.root))
100
+ self.assertFalse(self.shared.exists())
101
+
102
+ def test_native_lock_contention_defers_and_preserves_owner_inode(self):
103
+ self.seed()
104
+ directory = self.accounts[0] / 'mcp-oauth-locks'
105
+ directory.mkdir()
106
+ file_lock = directory / 'file-store.lock'
107
+ file_lock.touch()
108
+ file_inode = file_lock.stat().st_ino
109
+ path = directory / ('a' * 64 + '.lock')
110
+ with path.open('w') as handle:
111
+ inode = path.stat().st_ino
112
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
113
+ self.assertFalse(M.ensure(self.root))
114
+ self.assertFalse((self.accounts[1] / '.credentials.json').exists())
115
+ self.assertTrue(M.ensure(self.root))
116
+ self.assertEqual((self.shared / 'locks' / path.name).stat().st_ino, inode)
117
+ self.assertEqual((self.shared / 'locks' / 'file-store.lock').stat().st_ino, file_inode)
118
+ with (self.accounts[0] / 'mcp-oauth-locks' / path.name).open('a') as handle:
119
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
120
+ other = os.open(self.accounts[1] / 'mcp-oauth-locks' / path.name, os.O_RDWR)
121
+ try:
122
+ with self.assertRaises(BlockingIOError):
123
+ fcntl.flock(other, fcntl.LOCK_EX | fcntl.LOCK_NB)
124
+ finally:
125
+ os.close(other)
126
+
127
+ def test_concurrent_native_writers_preserve_both_servers(self):
128
+ self.seed()
129
+ self.assertTrue(M.ensure(self.root))
130
+ processes = []
131
+ context = multiprocessing.get_context('fork')
132
+ for index, account in enumerate(self.accounts):
133
+ name = 'server' + str(index)
134
+ process = context.Process(target=native_update, args=(account, name, dict(ENTRY, server_name=name)))
135
+ process.start()
136
+ processes.append(process)
137
+ for process in processes:
138
+ process.join(5)
139
+ self.assertEqual(process.exitcode, 0)
140
+ doc = json.loads((self.shared / 'credentials.json').read_text())
141
+ self.assertIn('server0', doc)
142
+ self.assertIn('server1', doc)
143
+
144
+
145
+ if __name__ == '__main__':
146
+ unittest.main()
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python3
2
+ """The documented default-home adoption is the only supported external account link."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
12
+ import codex_mcp_oauth as M
13
+
14
+ ENTRY = {'server_name': 'demo', 'server_url': 'https://example.invalid/mcp',
15
+ 'client_id': 'fake-client', 'access_token': 'fake-access', 'refresh_token': 'fake-refresh'}
16
+
17
+
18
+ class DefaultAdoptionTests(unittest.TestCase):
19
+ def setUp(self):
20
+ self.temp = tempfile.TemporaryDirectory()
21
+ self.addCleanup(self.temp.cleanup)
22
+ self.home = Path(self.temp.name).resolve()
23
+ environment = patch.dict(os.environ, {'HOME': str(self.home)})
24
+ environment.start()
25
+ self.addCleanup(environment.stop)
26
+ self.root = self.home / '.codex-accounts'
27
+ self.root.mkdir()
28
+ self.default = self.home / '.codex'
29
+ self.default.mkdir()
30
+ self.adopted = self.root / 'acct-01'
31
+ self.adopted.symlink_to(self.default, target_is_directory=True)
32
+ self.regular = self.root / 'acct-02'
33
+ self.regular.mkdir()
34
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}, {'id': 'acct-02'}]}))
35
+ (self.regular / '.credentials.json').write_text(json.dumps({'demo|1234': ENTRY}))
36
+ (self.default / 'auth.json').write_text('default-subscription-login')
37
+ (self.regular / 'auth.json').write_text('regular-subscription-login')
38
+
39
+ def test_adopted_default_joins_storage_preserving_logical_identity(self):
40
+ self.assertTrue(M.ensure(self.root, self.adopted))
41
+ authority = self.root / M.SHARED_DIR / 'credentials.json'
42
+ for path in (self.default, self.adopted, self.regular):
43
+ self.assertTrue(os.path.samefile(path / '.credentials.json', authority))
44
+ self.assertEqual((path / 'mcp-oauth-locks').resolve(), self.root / M.SHARED_DIR / 'locks')
45
+ receipt = json.loads((self.root / M.SHARED_DIR / 'managed.json').read_text())
46
+ self.assertEqual(receipt['accounts'], ['acct-01', 'acct-02'])
47
+ self.assertEqual((self.default / 'auth.json').read_text(), 'default-subscription-login')
48
+ self.assertEqual((self.regular / 'auth.json').read_text(), 'regular-subscription-login')
49
+ self.assertTrue(self.adopted.is_symlink())
50
+ self.assertTrue(M.ensure(self.root, self.default))
51
+ self.assertTrue(M.ensure(self.root, self.regular))
52
+
53
+ def test_foreign_adopted_target_and_duplicate_homes_are_rejected(self):
54
+ foreign = self.home / 'foreign'
55
+ foreign.mkdir()
56
+ self.adopted.unlink()
57
+ self.adopted.symlink_to(foreign, target_is_directory=True)
58
+ self.assertFalse(M.ensure(self.root, self.adopted))
59
+ self.assertEqual(list(foreign.iterdir()), [])
60
+ self.assertFalse((self.root / M.SHARED_DIR).exists())
61
+ self.adopted.unlink()
62
+ self.adopted.symlink_to(self.default, target_is_directory=True)
63
+ extra = self.root / 'acct-03'
64
+ extra.symlink_to(self.default, target_is_directory=True)
65
+ names = ['acct-01', 'acct-02', 'acct-03']
66
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': name} for name in names]}))
67
+ self.assertFalse(M.ensure(self.root, self.default))
68
+ self.assertFalse((self.root / M.SHARED_DIR).exists())
69
+ self.assertFalse((self.default / '.credentials.json').exists())
70
+
71
+ def test_unlisted_default_home_and_retargeted_account_are_rejected(self):
72
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-02'}]}))
73
+ self.assertFalse(M.ensure(self.root, self.default))
74
+ self.assertFalse((self.root / M.SHARED_DIR).exists())
75
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': 'acct-01'}, {'id': 'acct-02'}]}))
76
+ accounts = M.account_dirs(self.root)
77
+ foreign = self.home / 'foreign'
78
+ foreign.mkdir()
79
+ self.adopted.unlink()
80
+ self.adopted.symlink_to(foreign, target_is_directory=True)
81
+ with self.assertRaises(M.Deferred):
82
+ M.check_paths(self.root, accounts)
83
+ self.assertEqual(list(foreign.iterdir()), [])
84
+
85
+
86
+ if __name__ == '__main__':
87
+ unittest.main()
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env python3
2
+ """Migration keeps preexisting native lock descriptors serialized through cutover."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ import unittest
11
+ from unittest.mock import patch
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
14
+ import codex_mcp_oauth as M
15
+ import codex_mcp_oauth_locks as L
16
+ from codex_mcp_oauth_store import Deferred
17
+
18
+ DOCUMENT = {'demo|1234': {'server_name': 'demo', 'server_url': 'https://example.invalid',
19
+ 'client_id': 'fake-client', 'access_token': 'fake-access'}}
20
+ WAITER = '''import fcntl,os,sys,time
21
+ fd=os.open(sys.argv[1],os.O_RDWR)
22
+ print('opened',flush=True)
23
+ time.sleep(0.3)
24
+ try:
25
+ fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)
26
+ print('unsafe',flush=True)
27
+ except BlockingIOError:
28
+ print('blocked',flush=True)
29
+ os.close(fd)
30
+ '''
31
+
32
+
33
+ class MigrationTests(unittest.TestCase):
34
+ def setUp(self):
35
+ temporary = tempfile.TemporaryDirectory()
36
+ self.addCleanup(temporary.cleanup)
37
+ self.root = Path(temporary.name)
38
+ self.accounts = [self.root / 'acct-01', self.root / 'acct-02']
39
+ for account in self.accounts:
40
+ (account / 'mcp-oauth-locks').mkdir(parents=True)
41
+ (account / 'mcp-oauth-locks' / 'file-store.lock').touch()
42
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': p.name} for p in self.accounts]}))
43
+ self.source = self.accounts[0] / '.credentials.json'
44
+ self.source.write_text(json.dumps(DOCUMENT))
45
+ self.shared = self.root / M.SHARED_DIR / 'locks'
46
+
47
+ def test_open_unlocked_descriptor_defers_before_any_cutover(self):
48
+ path = self.accounts[1] / 'mcp-oauth-locks' / 'file-store.lock'
49
+ fd = os.open(path, os.O_RDWR)
50
+ try:
51
+ self.assertFalse(M.ensure(self.root))
52
+ self.assertTrue(all(not (account / 'mcp-oauth-locks').is_symlink() for account in self.accounts))
53
+ self.assertEqual(json.loads(self.source.read_text()), DOCUMENT)
54
+ self.assertFalse((self.accounts[1] / '.credentials.json').exists())
55
+ self.assertEqual(path.stat().st_ino, os.fstat(fd).st_ino)
56
+ finally:
57
+ os.close(fd)
58
+ self.assertTrue(M.ensure(self.root))
59
+
60
+ def test_waiter_opened_after_preflight_drains_before_credentials_are_shared(self):
61
+ original = L.exchange
62
+ children = []
63
+
64
+ def exchange_with_waiter(source, target):
65
+ if source.parent.name == self.accounts[1].name:
66
+ child = subprocess.Popen([sys.executable, '-c', WAITER, str(source / 'file-store.lock')],
67
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
68
+ children.append(child)
69
+ self.assertEqual(child.stdout.readline().strip(), 'opened')
70
+ self.assertFalse((self.accounts[1] / '.credentials.json').exists())
71
+ original(source, target)
72
+ self.assertTrue(source.is_symlink())
73
+ self.assertTrue(target.is_dir() and not target.is_symlink())
74
+
75
+ started = time.monotonic()
76
+ with patch.object(L, 'exchange', side_effect=exchange_with_waiter):
77
+ self.assertTrue(M.ensure(self.root))
78
+ self.assertGreaterEqual(time.monotonic() - started, 0.3)
79
+ for child in children:
80
+ output, error = child.communicate(timeout=5)
81
+ self.assertEqual((child.returncode, output.strip(), error), (0, 'blocked', ''))
82
+ self.assertTrue(os.path.samefile(self.source, self.accounts[1] / '.credentials.json'))
83
+ self.assertEqual(list((self.shared.parent / 'retired-locks').iterdir()), [])
84
+
85
+ def test_lock_created_after_snapshot_keeps_its_inode(self):
86
+ original = L.exchange
87
+ created = []
88
+ name = 'a' * 64 + '.lock'
89
+
90
+ def exchange_with_new_lock(source, target):
91
+ if source.parent.name == self.accounts[1].name:
92
+ path = source / name
93
+ path.touch()
94
+ created.append(path.stat().st_ino)
95
+ original(source, target)
96
+
97
+ with patch.object(L, 'exchange', side_effect=exchange_with_new_lock):
98
+ self.assertTrue(M.ensure(self.root))
99
+ self.assertEqual((self.shared / name).stat().st_ino, created[0])
100
+
101
+ def test_unavailable_exchange_defers_without_mutating_credentials(self):
102
+ with patch.object(L, 'exchange_supported', side_effect=Deferred('unavailable')):
103
+ self.assertFalse(M.ensure(self.root))
104
+ self.assertEqual(json.loads(self.source.read_text()), DOCUMENT)
105
+ self.assertTrue(all(not (account / 'mcp-oauth-locks').is_symlink() for account in self.accounts))
106
+
107
+ def test_completed_migration_never_probes_process_handles_again(self):
108
+ self.assertTrue(M.ensure(self.root))
109
+ with patch.object(L, 'external_handles', side_effect=AssertionError('unnecessary process probe')):
110
+ self.assertTrue(M.ensure(self.root))
111
+
112
+ def test_partial_cutover_failure_drains_waiter_before_releasing_locks(self):
113
+ third = self.root / 'acct-03'
114
+ (third / 'mcp-oauth-locks').mkdir(parents=True)
115
+ (third / 'mcp-oauth-locks' / 'file-store.lock').touch()
116
+ self.accounts.append(third)
117
+ (self.root / 'accounts.json').write_text(json.dumps({'accounts': [{'id': p.name} for p in self.accounts]}))
118
+ original, children = L.exchange, []
119
+
120
+ def interrupted_exchange(source, target):
121
+ if source.parent.name == 'acct-03':
122
+ raise Deferred('injected cutover failure')
123
+ if source.parent.name == 'acct-02':
124
+ child = subprocess.Popen([sys.executable, '-c', WAITER, str(source / 'file-store.lock')],
125
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
126
+ children.append(child)
127
+ self.assertEqual(child.stdout.readline().strip(), 'opened')
128
+ original(source, target)
129
+
130
+ with patch.object(L, 'exchange', side_effect=interrupted_exchange):
131
+ self.assertFalse(M.ensure(self.root))
132
+ self.assertEqual(json.loads(self.source.read_text()), DOCUMENT)
133
+ self.assertFalse((self.accounts[1] / '.credentials.json').exists())
134
+ for child in children:
135
+ output, error = child.communicate(timeout=5)
136
+ self.assertEqual((child.returncode, output.strip(), error), (0, 'blocked', ''))
137
+ self.assertTrue(M.ensure(self.root))
138
+
139
+
140
+ if __name__ == '__main__':
141
+ unittest.main()
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env python3
2
+ """Linux process exit must not hide a live native OAuth lock holder."""
3
+ import os
4
+ from pathlib import Path
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ import unittest
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
13
+ import codex_mcp_oauth_native as N
14
+ from codex_mcp_oauth_store import Deferred
15
+
16
+
17
+ def scan_process(process):
18
+ """Limit the proc fixture to this test's process, retaining real fd operations."""
19
+ root = MagicMock()
20
+ root.iterdir.return_value = [process]
21
+ with patch.object(N, 'Path', return_value=root):
22
+ return N.linux_handles(set(), set())
23
+
24
+
25
+ class NativeProcessTests(unittest.TestCase):
26
+ def test_denied_live_or_unproven_process_still_defers(self):
27
+ statuses = [
28
+ 'State:\tS (sleeping)\nThreads:\t1\n',
29
+ 'State:\tZ (zombie)\nThreads:\t2\n',
30
+ 'State:\tZ (zombie)\n',
31
+ 'State:\tZ (zombie)\nThreads:\tinvalid\n',
32
+ 'Threads:\t1\n',
33
+ ]
34
+ process = MagicMock(spec=Path)
35
+ process.name = '12345'
36
+ process.stat.return_value.st_uid = os.getuid()
37
+ process.__truediv__.return_value.iterdir.side_effect = PermissionError('denied')
38
+ for status in statuses:
39
+ with self.subTest(status=status):
40
+ process.__truediv__.return_value.read_text.return_value = status
41
+ with self.assertRaises(Deferred):
42
+ scan_process(process)
43
+
44
+ def test_denied_single_thread_exit_does_not_block_migration(self):
45
+ process = MagicMock(spec=Path)
46
+ process.name = '12345'
47
+ process.stat.return_value.st_uid = os.getuid()
48
+ process.__truediv__.return_value.iterdir.side_effect = PermissionError('denied')
49
+ for state in ('Z (zombie)', 'X (dead)'):
50
+ with self.subTest(state=state):
51
+ process.__truediv__.return_value.read_text.return_value = f'State:\t{state}\nThreads:\t1\n'
52
+ self.assertFalse(scan_process(process))
53
+
54
+ def test_unreadable_status_defers_but_vanished_process_does_not(self):
55
+ process = MagicMock(spec=Path)
56
+ process.name = '12345'
57
+ process.stat.return_value.st_uid = os.getuid()
58
+ process.__truediv__.return_value.iterdir.side_effect = PermissionError('denied')
59
+ process.__truediv__.return_value.read_text.side_effect = PermissionError('denied')
60
+ with self.assertRaises(Deferred):
61
+ scan_process(process)
62
+ process.__truediv__.return_value.read_text.side_effect = FileNotFoundError('gone')
63
+ self.assertFalse(scan_process(process))
64
+
65
+ @unittest.skipUnless(sys.platform == 'linux', 'requires Linux proc metadata')
66
+ def test_real_unreaped_child_releases_its_file_descriptors(self):
67
+ child = subprocess.Popen([sys.executable, '-c', 'pass'])
68
+ try:
69
+ os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOWAIT)
70
+ process = Path('/proc') / str(child.pid)
71
+ self.assertTrue(N.exited_process(process))
72
+ self.assertFalse(scan_process(process))
73
+ finally:
74
+ child.wait(timeout=3)
75
+
76
+ @unittest.skipUnless(sys.platform == 'linux', 'requires Linux thread-group behavior')
77
+ def test_zombie_leader_with_living_thread_still_defers(self):
78
+ script = '''import ctypes,os,sys,threading,time
79
+ ready=threading.Event()
80
+ def owner():
81
+ with open(sys.argv[1], 'w'):
82
+ ready.set()
83
+ time.sleep(10)
84
+ threading.Thread(target=owner).start()
85
+ ready.wait()
86
+ ctypes.CDLL(None).pthread_exit(None)
87
+ '''
88
+ with tempfile.TemporaryDirectory() as directory:
89
+ child = subprocess.Popen([sys.executable, '-c', script, str(Path(directory) / 'lock')])
90
+ try:
91
+ process = Path('/proc') / str(child.pid)
92
+ deadline = time.monotonic() + 3
93
+ while time.monotonic() < deadline:
94
+ status = (process / 'status').read_text()
95
+ if 'State:\tZ' in status and 'Threads:\t2' in status:
96
+ break
97
+ time.sleep(0.01)
98
+ else:
99
+ self.fail('fixture did not leave a zombie leader with a living thread')
100
+ self.assertFalse(N.exited_process(process))
101
+ with self.assertRaises(Deferred):
102
+ scan_process(process)
103
+ finally:
104
+ child.kill()
105
+ child.wait(timeout=3)
106
+
107
+
108
+ if __name__ == '__main__':
109
+ unittest.main()