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.
- package/README.md +6 -2
- package/bin/codex +30 -7
- package/bin/codex-accounts +12 -4
- package/docs/ACCOUNT_OPERATIONS.md +2 -2
- package/docs/AUTORESUME.md +6 -1
- package/docs/CODEX.md +19 -2
- package/docs/CODEX_MCP_AUTH.md +58 -0
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/autoresume.py +6 -5
- package/lib/autoresume_cross.py +2 -1
- package/lib/autoresume_handoff.py +13 -5
- package/lib/codex_exec_stdin.sh +45 -0
- package/lib/codex_mcp_oauth.py +204 -0
- package/lib/codex_mcp_oauth.sh +29 -0
- package/lib/codex_mcp_oauth_locks.py +158 -0
- package/lib/codex_mcp_oauth_native.py +102 -0
- package/lib/codex_mcp_oauth_store.py +149 -0
- package/lib/codex_resume_cwd.py +57 -0
- package/lib/codex_retry_process.sh +37 -0
- package/lib/common.sh +3 -0
- package/package.json +9 -4
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/fake_codex_tui.py +23 -1
- package/tests/test_autoresume_cwd.py +71 -0
- package/tests/test_autoresume_cwd_handoff.py +92 -0
- package/tests/test_autoresume_cwd_reader.py +105 -0
- package/tests/test_autoresume_cwd_tmux.py +54 -0
- package/tests/test_codex_exec_stdin.py +178 -0
- package/tests/test_codex_mcp_oauth.py +146 -0
- package/tests/test_codex_mcp_oauth_adopt.py +87 -0
- package/tests/test_codex_mcp_oauth_migration.py +141 -0
- package/tests/test_codex_mcp_oauth_native.py +109 -0
- package/tests/test_codex_mcp_oauth_safety.py +130 -0
- package/tests/test_codex_mcp_oauth_shim.py +188 -0
- 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)
|