claude-multiacc 2.0.61 → 2.0.64

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 (137) hide show
  1. package/README.md +6 -2
  2. package/bin/codex +30 -7
  3. package/bin/codex-accounts +12 -4
  4. package/docs/ACCOUNT_OPERATIONS.md +2 -2
  5. package/docs/AUTORESUME.md +6 -1
  6. package/docs/CODEX.md +19 -2
  7. package/docs/CODEX_MCP_AUTH.md +58 -0
  8. package/lib/__pycache__/audit.cpython-312.pyc +0 -0
  9. package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
  10. package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
  11. package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
  12. package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
  13. package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
  14. package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
  15. package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
  16. package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
  17. package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
  18. package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
  19. package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
  20. package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
  21. package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
  22. package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
  23. package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
  24. package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
  25. package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
  26. package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
  27. package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
  28. package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
  29. package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
  30. package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
  31. package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
  32. package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
  33. package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
  34. package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
  35. package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
  36. package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
  37. package/lib/__pycache__/report.cpython-312.pyc +0 -0
  38. package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
  39. package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
  40. package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
  41. package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
  42. package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
  43. package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
  44. package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
  45. package/lib/autoresume.py +6 -5
  46. package/lib/autoresume_cross.py +2 -1
  47. package/lib/autoresume_handoff.py +13 -5
  48. package/lib/codex_exec_stdin.sh +45 -0
  49. package/lib/codex_mcp_oauth.py +204 -0
  50. package/lib/codex_mcp_oauth.sh +29 -0
  51. package/lib/codex_mcp_oauth_locks.py +158 -0
  52. package/lib/codex_mcp_oauth_native.py +102 -0
  53. package/lib/codex_mcp_oauth_store.py +149 -0
  54. package/lib/codex_resume_cwd.py +57 -0
  55. package/lib/codex_retry_process.sh +37 -0
  56. package/lib/common.sh +3 -0
  57. package/package.json +9 -4
  58. package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
  59. package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
  60. package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
  61. package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
  62. package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
  63. package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
  64. package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
  65. package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
  66. package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
  67. package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
  68. package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
  69. package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
  70. package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
  71. package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
  72. package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
  73. package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
  74. package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
  75. package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
  76. package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
  77. package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
  78. package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
  79. package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
  80. package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
  81. package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
  82. package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
  83. package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
  84. package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
  85. package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
  86. package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
  87. package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
  88. package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
  89. package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
  90. package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
  91. package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
  92. package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
  93. package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
  94. package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
  95. package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
  96. package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
  97. package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
  98. package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
  99. package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
  100. package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
  101. package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
  102. package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
  103. package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
  104. package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
  105. package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
  106. package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
  107. package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
  108. package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
  109. package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
  110. package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
  111. package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
  112. package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
  113. package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
  114. package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
  115. package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
  116. package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
  117. package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
  118. package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
  119. package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
  120. package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
  121. package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
  122. package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
  123. package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
  124. package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
  125. package/tests/fake_codex_tui.py +23 -1
  126. package/tests/test_autoresume_cwd.py +71 -0
  127. package/tests/test_autoresume_cwd_handoff.py +92 -0
  128. package/tests/test_autoresume_cwd_reader.py +105 -0
  129. package/tests/test_autoresume_cwd_tmux.py +54 -0
  130. package/tests/test_codex_exec_stdin.py +178 -0
  131. package/tests/test_codex_mcp_oauth.py +146 -0
  132. package/tests/test_codex_mcp_oauth_adopt.py +87 -0
  133. package/tests/test_codex_mcp_oauth_migration.py +141 -0
  134. package/tests/test_codex_mcp_oauth_native.py +109 -0
  135. package/tests/test_codex_mcp_oauth_safety.py +130 -0
  136. package/tests/test_codex_mcp_oauth_shim.py +188 -0
  137. package/tests/test_codex_retry_process.py +143 -0
@@ -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)
@@ -0,0 +1,102 @@
1
+ """Native lock migration primitives; inspect only task-owned lock file metadata."""
2
+ from __future__ import annotations
3
+
4
+ import ctypes
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+
11
+ from codex_mcp_oauth_store import Deferred
12
+
13
+
14
+ def exchange_supported():
15
+ libc = ctypes.CDLL(None, use_errno=True)
16
+ name = 'renamex_np' if sys.platform == 'darwin' else 'renameat2'
17
+ if sys.platform not in ('darwin', 'linux') or not hasattr(libc, name):
18
+ raise Deferred('atomic native lock migration is unavailable')
19
+ if sys.platform == 'darwin' and not shutil.which('lsof'):
20
+ raise Deferred('native lock migration requires lsof')
21
+ if sys.platform == 'linux' and not Path('/proc/self/fd').is_dir():
22
+ raise Deferred('native lock migration requires process file metadata')
23
+
24
+
25
+ def exchange(source, target):
26
+ """Swap a directory and prepared symlink without an absent native pathname."""
27
+ libc = ctypes.CDLL(None, use_errno=True)
28
+ left, right = os.fsencode(source), os.fsencode(target)
29
+ if sys.platform == 'darwin':
30
+ result = libc.renamex_np(ctypes.c_char_p(left), ctypes.c_char_p(right), ctypes.c_uint(2))
31
+ else:
32
+ result = libc.renameat2(-100, ctypes.c_char_p(left), -100, ctypes.c_char_p(right), ctypes.c_uint(2))
33
+ if result:
34
+ raise Deferred('atomic native lock migration failed')
35
+
36
+
37
+ def linux_handles(identities, owned_fds):
38
+ for process in Path('/proc').iterdir():
39
+ if not process.name.isdecimal():
40
+ continue
41
+ try:
42
+ if process.stat().st_uid != os.getuid():
43
+ continue
44
+ for descriptor in (process / 'fd').iterdir():
45
+ if int(process.name) == os.getpid() and int(descriptor.name) in owned_fds:
46
+ continue
47
+ try:
48
+ info = descriptor.stat()
49
+ if (info.st_dev, info.st_ino) in identities:
50
+ return True
51
+ except FileNotFoundError:
52
+ continue
53
+ except FileNotFoundError:
54
+ continue
55
+ except PermissionError as exc:
56
+ if exited_process(process):
57
+ continue
58
+ raise Deferred('native lock handles could not be inspected') from exc
59
+ return False
60
+
61
+
62
+ def exited_process(process):
63
+ """A reaped or single-thread zombie process cannot retain a native lock fd."""
64
+ try:
65
+ fields = dict(line.split(':', 1) for line in (process / 'status').read_text().splitlines()
66
+ if ':' in line)
67
+ except FileNotFoundError:
68
+ return True
69
+ except OSError:
70
+ return False
71
+ # A zombie thread-group leader can still have living threads and open files.
72
+ state = fields.get('State', '').split()
73
+ return bool(state and state[0] in ('Z', 'X') and fields.get('Threads', '').strip() == '1')
74
+
75
+
76
+ def mac_handles(paths, owned_fds):
77
+ try:
78
+ result = subprocess.run(['lsof', '-nP', '-Fpf', '--', *map(str, paths)],
79
+ capture_output=True, text=True, timeout=5, check=False)
80
+ except (OSError, subprocess.TimeoutExpired) as exc:
81
+ raise Deferred('native lock handles could not be inspected') from exc
82
+ if result.returncode not in (0, 1) or result.returncode == 1 and result.stderr:
83
+ raise Deferred('native lock handles could not be inspected')
84
+ pid = None
85
+ for line in result.stdout.splitlines():
86
+ if line.startswith('p') and line[1:].isdecimal():
87
+ pid = int(line[1:])
88
+ elif line.startswith('f'):
89
+ number = line[1:].rstrip('ruw')
90
+ if pid != os.getpid() or not number.isdecimal() or int(number) not in owned_fds:
91
+ return True
92
+ return False
93
+
94
+
95
+ def external_handles(paths, owned_fds):
96
+ paths = list(paths)
97
+ if not paths:
98
+ return False
99
+ identities = {(path.stat().st_dev, path.stat().st_ino) for path in paths}
100
+ if sys.platform == 'linux':
101
+ return linux_handles(identities, owned_fds)
102
+ return mac_handles(paths, owned_fds)
@@ -0,0 +1,149 @@
1
+ """Native Codex fallback document validation and shared-inode persistence."""
2
+ from __future__ import annotations
3
+
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import stat
8
+ from pathlib import Path
9
+
10
+ SENTINEL = '__multiacc_shared_store__|0000000000000000'
11
+ PLACEHOLDER = {
12
+ 'server_name': '__multiacc_shared_store__', 'server_url': 'urn:claude-multiacc:shared-mcp-store',
13
+ 'client_id': '', 'access_token': '', 'refresh_token': None, 'expires_at': 0, 'scopes': [],
14
+ }
15
+ MAX_BYTES = 4 << 20
16
+
17
+
18
+ class Deferred(Exception):
19
+ """Leave an unsafe or contended store unchanged; never include credential values."""
20
+
21
+
22
+ def read_json(path):
23
+ try:
24
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
25
+ except FileNotFoundError:
26
+ return {}
27
+ with os.fdopen(fd, 'r', encoding='utf-8') as stream:
28
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
29
+ raise Deferred('non-regular document')
30
+ raw = stream.read(MAX_BYTES + 1)
31
+ if len(raw) > MAX_BYTES:
32
+ raise Deferred('oversized document')
33
+ doc = json.loads(raw)
34
+ if not isinstance(doc, dict):
35
+ raise Deferred('invalid document')
36
+ return doc
37
+
38
+
39
+ def validate(doc):
40
+ identities = {}
41
+ for key, entry in doc.items():
42
+ if key == SENTINEL:
43
+ if entry != PLACEHOLDER:
44
+ raise Deferred('reserved identity conflict')
45
+ continue
46
+ if not isinstance(entry, dict) or not isinstance(key, str):
47
+ raise Deferred('invalid credential entry')
48
+ for field in ('server_name', 'server_url', 'client_id', 'access_token'):
49
+ if not isinstance(entry.get(field), str):
50
+ raise Deferred('invalid credential fields')
51
+ if 'executor_owned' in entry and type(entry['executor_owned']) is not bool:
52
+ raise Deferred('invalid credential owner')
53
+ name = entry['server_name']
54
+ if entry.get('executor_owned') or name.startswith(('executor:', 'ema-idp:')) or key.startswith('ema-idp:'):
55
+ raise Deferred('scoped credential cannot be shared')
56
+ if name.removeprefix('local:') == PLACEHOLDER['server_name']:
57
+ raise Deferred('reserved identity conflict')
58
+ issuer = entry.get('issuer')
59
+ if issuer is not None and not isinstance(issuer, str):
60
+ raise Deferred('invalid issuer field')
61
+ refresh, expiry = entry.get('refresh_token'), entry.get('expires_at')
62
+ scopes = entry.get('scopes', [])
63
+ if (refresh is not None and not isinstance(refresh, str)
64
+ or expiry is not None and (type(expiry) is not int or expiry < 0)
65
+ or not isinstance(scopes, list) or not all(isinstance(value, str) for value in scopes)):
66
+ raise Deferred('invalid credential fields')
67
+ identity = (name.removeprefix('local:'), entry['server_url'])
68
+ if identity in identities and identities[identity] != (key, entry):
69
+ raise Deferred('duplicate credential identity')
70
+ identities[identity] = (key, entry)
71
+ return doc
72
+
73
+
74
+ def merge(documents):
75
+ result = {}
76
+ grants = [{key: entry for key, entry in doc.items() if key != SENTINEL} for doc in documents]
77
+ populated = [doc for doc in grants if doc]
78
+ if populated and any(doc != populated[0] for doc in populated[1:]):
79
+ raise Deferred('independent credential documents require separate authorization')
80
+ for doc in documents:
81
+ validate(doc)
82
+ for key, entry in doc.items():
83
+ if key in result and result[key] != entry:
84
+ raise Deferred('conflicting credential grants')
85
+ result[key] = entry
86
+ validate(result)
87
+ result[SENTINEL] = dict(PLACEHOLDER)
88
+ return result
89
+
90
+
91
+ def write_shared(path, doc):
92
+ """Native Codex truncates in place too; callers hold its file-store lock."""
93
+ data = json.dumps(doc, separators=(',', ':')).encode()
94
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
95
+ with os.fdopen(fd, 'wb') as stream:
96
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
97
+ raise Deferred('non-regular shared document')
98
+ os.fchmod(stream.fileno(), 0o600)
99
+ stream.truncate(0)
100
+ stream.write(data)
101
+ stream.flush()
102
+ os.fsync(stream.fileno())
103
+
104
+
105
+ @contextlib.contextmanager
106
+ def staged_links(source, accounts, locks):
107
+ """Prove every hardlink and symlink can be created before changing native state."""
108
+ pending, scratch_paths = [], []
109
+ try:
110
+ for account in accounts:
111
+ if source.stat().st_dev != account.stat().st_dev:
112
+ raise Deferred('account credentials are on different filesystems')
113
+ target = account / '.credentials.json'
114
+ if not target.exists() or not os.path.samefile(source, target):
115
+ scratch = account / '.codex-mcp-oauth-link'
116
+ if scratch.is_symlink() or scratch.exists() and not os.path.samefile(source, scratch):
117
+ raise Deferred('foreign staged credential link')
118
+ if not scratch.exists():
119
+ os.link(source, scratch)
120
+ scratch_paths.append(scratch)
121
+ pending.append((scratch, target))
122
+ if not (account / 'mcp-oauth-locks').is_symlink():
123
+ scratch = account / '.codex-mcp-oauth-locks-link'
124
+ if scratch.is_symlink():
125
+ if scratch.resolve() != locks:
126
+ raise Deferred('foreign staged native lock link')
127
+ elif scratch.exists():
128
+ raise Deferred('foreign staged native lock link')
129
+ else:
130
+ scratch.symlink_to(locks, target_is_directory=True)
131
+ scratch_paths.append(scratch)
132
+ yield pending
133
+ finally:
134
+ for scratch in scratch_paths:
135
+ scratch.unlink(missing_ok=True)
136
+
137
+
138
+ def commit_links(pending):
139
+ for scratch, target in pending:
140
+ os.replace(scratch, target)
141
+
142
+
143
+ def save_receipt(path, accounts):
144
+ document = {'accounts': sorted(item.name for item in accounts)}
145
+ if read_json(path) == document:
146
+ return
147
+ scratch = path.with_name('managed.pending.json')
148
+ write_shared(scratch, document)
149
+ os.replace(scratch, path)