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