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,57 @@
|
|
|
1
|
+
"""Recover Codex's saved working directory from its bound main-thread rollout."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from session_settings import _record, _reverse_lines
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _directory(value):
|
|
9
|
+
if isinstance(value, str) and os.path.isabs(value) and not any(c in value for c in "\0\n\r"):
|
|
10
|
+
return value
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _record_cwd(doc, own):
|
|
15
|
+
payload = doc.get("payload") if doc else None
|
|
16
|
+
if not isinstance(payload, dict) or payload.get("thread_id") not in (None, own):
|
|
17
|
+
return None
|
|
18
|
+
if doc.get("type") == "turn_context":
|
|
19
|
+
turn, root = payload.get("turn_id"), payload.get("root_turn_id")
|
|
20
|
+
if turn and root and turn != root:
|
|
21
|
+
return None
|
|
22
|
+
return _directory(payload.get("cwd"))
|
|
23
|
+
if doc.get("type") == "event_msg" and payload.get("type") == "thread_settings_applied":
|
|
24
|
+
settings = payload.get("thread_settings")
|
|
25
|
+
return _directory(settings.get("cwd")) if isinstance(settings, dict) else None
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def codex_resume_cwd(path, sid=None, upto=None):
|
|
30
|
+
"""Latest saved main-thread cwd, then its initial cwd; unknown/unreadable gives None.
|
|
31
|
+
|
|
32
|
+
Match the native resume picker: a settings change can update cwd before the next
|
|
33
|
+
turn_context. Read independently of the watcher's quota cursor so chained restarts
|
|
34
|
+
still recover settings written before that cursor. Only the first metadata record
|
|
35
|
+
identifies this thread; a fork can contain its parent's metadata later in the file.
|
|
36
|
+
``upto`` bounds history to a validated handoff snapshot's byte boundary.
|
|
37
|
+
"""
|
|
38
|
+
if not path:
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
with open(path, "rb") as fh:
|
|
42
|
+
meta = _record(fh.readline(4 << 20))
|
|
43
|
+
payload = meta.get("payload") if meta and meta.get("type") == "session_meta" else None
|
|
44
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("id"), str):
|
|
45
|
+
return None
|
|
46
|
+
own = payload["id"]
|
|
47
|
+
if sid is not None and sid != own:
|
|
48
|
+
return None
|
|
49
|
+
for line in _reverse_lines(path, upto):
|
|
50
|
+
if b'"cwd"' not in line:
|
|
51
|
+
continue
|
|
52
|
+
directory = _record_cwd(_record(line), own)
|
|
53
|
+
if directory is not None:
|
|
54
|
+
return directory
|
|
55
|
+
return _directory(payload.get("cwd"))
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sourced only by the buffered exec retry path. The caller keeps prompt input on fd 3
|
|
3
|
+
# and owns stdout/stderr redirections and its EXIT cleanup; this helper preserves them.
|
|
4
|
+
|
|
5
|
+
codex_retry_signal() {
|
|
6
|
+
codex_retry_pending_signal="$1"
|
|
7
|
+
codex_retry_signal_status="$2"
|
|
8
|
+
# A signal can arrive between installing the traps and assigning the async PID.
|
|
9
|
+
# Remember it; codex_retry_run delivers it as soon as the owned PID is known.
|
|
10
|
+
[ -n "$codex_retry_child_pid" ] || return 0
|
|
11
|
+
trap '' TERM INT HUP
|
|
12
|
+
kill -s "$codex_retry_pending_signal" "$codex_retry_child_pid" 2>/dev/null || true
|
|
13
|
+
wait "$codex_retry_child_pid" 2>/dev/null || true
|
|
14
|
+
codex_retry_child_pid=""
|
|
15
|
+
exit "$codex_retry_signal_status"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
codex_retry_run() {
|
|
19
|
+
local codex_retry_child_pid="" codex_retry_pending_signal="" codex_retry_signal_status=0
|
|
20
|
+
local codex_retry_previous_traps rc
|
|
21
|
+
codex_retry_previous_traps="$(trap -p TERM INT HUP)"
|
|
22
|
+
trap 'codex_retry_signal TERM 143' TERM
|
|
23
|
+
trap 'codex_retry_signal INT 130' INT
|
|
24
|
+
trap 'codex_retry_signal HUP 129' HUP
|
|
25
|
+
[ -z "$codex_retry_pending_signal" ] || exit "$codex_retry_signal_status"
|
|
26
|
+
"$@" <&3 &
|
|
27
|
+
codex_retry_child_pid=$!
|
|
28
|
+
if [ -n "$codex_retry_pending_signal" ]; then
|
|
29
|
+
codex_retry_signal "$codex_retry_pending_signal" "$codex_retry_signal_status"
|
|
30
|
+
fi
|
|
31
|
+
if wait "$codex_retry_child_pid"; then rc=0; else rc=$?; fi
|
|
32
|
+
codex_retry_child_pid=""
|
|
33
|
+
trap - TERM INT HUP
|
|
34
|
+
# trap -p emits shell-quoted definitions from this process, never fetched input.
|
|
35
|
+
eval "$codex_retry_previous_traps"
|
|
36
|
+
return "$rc"
|
|
37
|
+
}
|
package/lib/common.sh
CHANGED
|
@@ -30,6 +30,8 @@ DEFAULT_SERVER="root@138.197.36.107"
|
|
|
30
30
|
DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
31
31
|
|
|
32
32
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"
|
|
33
|
+
codex_mcp_oauth_ensure() { :; }
|
|
34
|
+
if [ -f "$LIB_DIR/codex_mcp_oauth.sh" ]; then . "$LIB_DIR/codex_mcp_oauth.sh"; fi
|
|
33
35
|
|
|
34
36
|
# INSTANCE-SCOPED POOLS: <PROVIDER>_ACCOUNTS_ROOT relocates the whole pool, so
|
|
35
37
|
# several app-robot instances on one machine/user keep completely isolated account
|
|
@@ -806,6 +808,7 @@ seed_account_dir() { # $1 = acct dir
|
|
|
806
808
|
fi
|
|
807
809
|
codex_share_state_index "$d"
|
|
808
810
|
seed_mcp_registry "$d"
|
|
811
|
+
codex_mcp_oauth_ensure "$ACC_ROOT" "$PYBIN" "$LIB_DIR/codex_mcp_oauth.py" "$d"
|
|
809
812
|
return 0
|
|
810
813
|
fi
|
|
811
814
|
if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.64",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,11 +56,16 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
|
-
"test:core": "bash tests/run-tests.sh && npm run test:selector &&
|
|
59
|
+
"test:core": "bash tests/run-tests.sh && npm run test:selector && npm run test:shim",
|
|
60
|
+
"test:shim": "python3 tests/test_shim_path.py && npm run test:codex-stdin",
|
|
61
|
+
"test:codex-stdin": "python3 tests/test_codex_exec_stdin.py && python3 tests/test_codex_retry_process.py",
|
|
60
62
|
"test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
|
|
61
|
-
"test:seeding": "
|
|
63
|
+
"test:seeding": "npm run test:account-seeding && npm run test:mcp && npm run test:codex-settings",
|
|
62
64
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
63
|
-
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
65
|
+
"test:commands": "python3 tests/test_packaged_commands.py",
|
|
66
|
+
"test:codex-mcp-auth": "python3 -m unittest discover -s tests -p 'test_codex_mcp_oauth*.py'",
|
|
67
|
+
"test:account-seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py",
|
|
68
|
+
"test:mcp": "python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-mcp-auth"
|
|
64
69
|
},
|
|
65
70
|
"dependencies": {
|
|
66
71
|
"update-notifier": "^7.3.1"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/fake_codex_tui.py
CHANGED
|
@@ -51,6 +51,27 @@ def initialize_rollout():
|
|
|
51
51
|
return rollout
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
def require_resume_cwd():
|
|
55
|
+
if os.environ.get("FAKE_REQUIRE_RESUME_CWD") != "1" or "resume" not in sys.argv:
|
|
56
|
+
return
|
|
57
|
+
path = Path(os.environ["CODEX_HOME"]) / "sessions/2026/10/10"
|
|
58
|
+
path /= "rollout-test-" + os.environ["FAKE_SID"] + ".jsonl"
|
|
59
|
+
saved = None
|
|
60
|
+
for line in path.read_text().splitlines():
|
|
61
|
+
row = json.loads(line)
|
|
62
|
+
payload = row.get("payload", {})
|
|
63
|
+
if row.get("type") in ("session_meta", "turn_context"):
|
|
64
|
+
saved = payload.get("cwd", saved)
|
|
65
|
+
elif row.get("type") == "event_msg" and payload.get("type") == "thread_settings_applied":
|
|
66
|
+
saved = payload.get("thread_settings", {}).get("cwd", saved)
|
|
67
|
+
matches = saved is None or os.path.realpath(saved) == os.getcwd()
|
|
68
|
+
record("resume-cwd", saved=saved, matches=matches)
|
|
69
|
+
if not matches:
|
|
70
|
+
print("Working directory · resume\n› 1. Use session directory\n 2. Use current directory", flush=True)
|
|
71
|
+
while True:
|
|
72
|
+
time.sleep(0.1)
|
|
73
|
+
|
|
74
|
+
|
|
54
75
|
def require_resume_trust():
|
|
55
76
|
if os.environ.get("FAKE_REQUIRE_TRUST") != "1" or "resume" not in sys.argv:
|
|
56
77
|
return
|
|
@@ -67,7 +88,7 @@ def require_resume_trust():
|
|
|
67
88
|
table = re.escape(json.dumps(os.getcwd()))
|
|
68
89
|
trusted = re.search(r'^\s*\[projects\.' + table + r'\]\s*\n(?:(?!\s*\[).*\n)*?\s*trust_level\s*=\s*"trusted"',
|
|
69
90
|
text, re.M) is not None
|
|
70
|
-
record("trust", trusted=trusted)
|
|
91
|
+
record("trust", trusted=trusted, cwd=os.getcwd())
|
|
71
92
|
if not trusted:
|
|
72
93
|
print("Folder access\nTrust this folder?\n› 1. Trust and continue\n 2. Quit", flush=True)
|
|
73
94
|
while True:
|
|
@@ -85,6 +106,7 @@ def main():
|
|
|
85
106
|
settings = termios.tcgetattr(0)
|
|
86
107
|
signal.signal(signal.SIGTERM, stop)
|
|
87
108
|
signal.signal(signal.SIGHUP, stop)
|
|
109
|
+
require_resume_cwd()
|
|
88
110
|
require_resume_trust()
|
|
89
111
|
rollout = initialize_rollout()
|
|
90
112
|
try:
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Account rotation restores the bound Codex session's last working directory."""
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
import test_autoresume as fixtures
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CodexResumeDirectoryTests(unittest.TestCase):
|
|
9
|
+
def setUp(self):
|
|
10
|
+
self.pool = fixtures.CodexWatcherTests(methodName="runTest")
|
|
11
|
+
self.pool.setUp()
|
|
12
|
+
self.addCleanup(self.pool.tearDown)
|
|
13
|
+
self.saved = self.pool.tmp / "saved project ü"
|
|
14
|
+
self.saved.mkdir()
|
|
15
|
+
|
|
16
|
+
def restart(self, records, expected, setup=None):
|
|
17
|
+
pool = self.pool
|
|
18
|
+
records.append(fixtures.cx_error(pool.t0, "usage_limit_exceeded"))
|
|
19
|
+
result, _, runner, _ = pool.run_codex(["resume", fixtures.CX_SID], records, setup=setup)
|
|
20
|
+
self.assertEqual(result, 0)
|
|
21
|
+
fields, argv = pool.relaunch_of(runner)
|
|
22
|
+
self.assertEqual(fields["cwd"], str(expected))
|
|
23
|
+
self.assertEqual(runner.probes[0]["cwd"], str(expected))
|
|
24
|
+
self.assertEqual(argv[-2], fixtures.CX_SID)
|
|
25
|
+
self.assertEqual(pool.events(), ["watch", "detect", "switch"])
|
|
26
|
+
|
|
27
|
+
def test_recorded_session_directory_wins_over_launch_directory(self):
|
|
28
|
+
self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved))], self.saved)
|
|
29
|
+
|
|
30
|
+
def test_latest_turn_directory_wins_over_initial_session_directory(self):
|
|
31
|
+
self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.pool.work)),
|
|
32
|
+
{"type": "turn_context", "payload": {"cwd": str(self.saved)}}], self.saved)
|
|
33
|
+
|
|
34
|
+
def test_latest_own_thread_settings_win_without_a_new_turn(self):
|
|
35
|
+
settings = {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID,
|
|
36
|
+
"thread_settings": {"cwd": str(self.saved)}}
|
|
37
|
+
self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.pool.work)),
|
|
38
|
+
{"type": "turn_context", "payload": {"cwd": str(self.pool.work)}},
|
|
39
|
+
fixtures.cx_event(self.pool.t0 - 1, settings)], self.saved)
|
|
40
|
+
|
|
41
|
+
def test_foreign_thread_settings_and_tool_workdirs_do_not_change_directory(self):
|
|
42
|
+
foreign = {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID2,
|
|
43
|
+
"thread_settings": {"cwd": str(self.pool.work)}}
|
|
44
|
+
tool = {"type": "response_item", "payload": {"type": "function_call", "name": "exec_command",
|
|
45
|
+
"arguments": '{"workdir":"/tmp","cmd":"pwd"}'}}
|
|
46
|
+
self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved)),
|
|
47
|
+
fixtures.cx_event(self.pool.t0 - 1, foreign), tool], self.saved)
|
|
48
|
+
|
|
49
|
+
def test_invalid_saved_values_keep_last_valid_directory(self):
|
|
50
|
+
invalid = [None, 7, {}, "relative/project", "/tmp\nnot-a-directory", "/tmp\0bad"]
|
|
51
|
+
records = [fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved))]
|
|
52
|
+
records.extend({"type": "turn_context", "payload": {"cwd": value}} for value in invalid)
|
|
53
|
+
self.restart(records, self.saved)
|
|
54
|
+
|
|
55
|
+
def test_deleted_saved_directory_falls_back_to_existing_launch_directory(self):
|
|
56
|
+
self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.saved / "deleted"))],
|
|
57
|
+
self.pool.work)
|
|
58
|
+
|
|
59
|
+
def test_relaunch_cursor_does_not_hide_the_saved_directory(self):
|
|
60
|
+
def setup(watcher, runner, path):
|
|
61
|
+
info = path.stat()
|
|
62
|
+
watcher.st["rollout_cursor"] = "%d:%d" % (info.st_ino, info.st_size)
|
|
63
|
+
runner.at(self.pool.t0 + 1, lambda: fixtures.append(path, [
|
|
64
|
+
fixtures.cx_error(self.pool.t0 + 1, "usage_limit_exceeded")]))
|
|
65
|
+
|
|
66
|
+
self.restart([fixtures.cx_meta(self.pool.launched - 100, cwd=str(self.pool.work)),
|
|
67
|
+
{"type": "turn_context", "payload": {"cwd": str(self.saved)}}], self.saved, setup=setup)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
unittest.main()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""A Codex handoff binds the saved directory and history to one validated snapshot."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
import test_autoresume as fixtures
|
|
10
|
+
from autoresume_cross_unit_support import scenario
|
|
11
|
+
import autoresume_cross as cross
|
|
12
|
+
import autoresume_handoff as handoff
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CodexDirectoryHandoffTests(unittest.TestCase):
|
|
16
|
+
def setUp(self):
|
|
17
|
+
temporary = tempfile.TemporaryDirectory(prefix="ar-cwd-handoff-")
|
|
18
|
+
self.addCleanup(temporary.cleanup)
|
|
19
|
+
self.home = Path(temporary.name).resolve()
|
|
20
|
+
self.initial = self.home / "initial"
|
|
21
|
+
self.saved = self.home / "saved project"
|
|
22
|
+
self.initial.mkdir()
|
|
23
|
+
self.saved.mkdir()
|
|
24
|
+
self.path = self.home / ("rollout-test-" + fixtures.CX_SID + ".jsonl")
|
|
25
|
+
self.meta = fixtures.cx_meta(1000, cwd=str(self.initial))
|
|
26
|
+
|
|
27
|
+
def write(self, records):
|
|
28
|
+
self.path.write_text("".join(json.dumps(row) + "\n" for row in [self.meta] + records))
|
|
29
|
+
|
|
30
|
+
def validate(self, cwd=None):
|
|
31
|
+
return handoff.validate_source("codex", fixtures.CX_SID, str(self.path), str(cwd or self.saved))
|
|
32
|
+
|
|
33
|
+
def test_changed_directory_from_turn_or_settings_replaces_initial_directory(self):
|
|
34
|
+
turn = {"type": "turn_context", "payload": {"cwd": str(self.saved)}}
|
|
35
|
+
settings = fixtures.cx_event(1001, {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID,
|
|
36
|
+
"thread_settings": {"cwd": str(self.saved)}})
|
|
37
|
+
for record in (turn, settings):
|
|
38
|
+
with self.subTest(kind=record["type"]):
|
|
39
|
+
self.write([record])
|
|
40
|
+
history = self.validate()
|
|
41
|
+
self.assertEqual(history["cwd"], str(self.saved))
|
|
42
|
+
self.assertEqual(history["offset"], self.path.stat().st_size)
|
|
43
|
+
self.assertIsNone(self.validate(self.initial))
|
|
44
|
+
|
|
45
|
+
def test_foreign_settings_cannot_authorize_unrelated_directory(self):
|
|
46
|
+
settings = fixtures.cx_event(1001, {"type": "thread_settings_applied", "thread_id": fixtures.CX_SID2,
|
|
47
|
+
"thread_settings": {"cwd": str(self.saved)}})
|
|
48
|
+
self.write([settings])
|
|
49
|
+
self.assertIsNone(self.validate())
|
|
50
|
+
self.assertIsNotNone(self.validate(self.initial))
|
|
51
|
+
|
|
52
|
+
def test_saved_directory_after_large_history_is_read_beyond_initial_prefix(self):
|
|
53
|
+
self.write([{"type": "response_item", "payload": {"text": "x" * handoff.MAX_SOURCE_BYTES}},
|
|
54
|
+
{"type": "turn_context", "payload": {"cwd": str(self.saved)}}])
|
|
55
|
+
self.assertEqual(self.validate()["cwd"], str(self.saved))
|
|
56
|
+
|
|
57
|
+
def test_appended_settings_after_snapshot_do_not_change_snapshot_directory(self):
|
|
58
|
+
self.write([{ "type": "turn_context", "payload": {"cwd": str(self.saved)}}])
|
|
59
|
+
offset = self.path.stat().st_size
|
|
60
|
+
original = handoff.codex_resume_cwd
|
|
61
|
+
later = {"type": "turn_context", "payload": {"cwd": str(self.initial)}}
|
|
62
|
+
with mock.patch.object(handoff, "codex_resume_cwd", side_effect=lambda *args, **kwargs: (
|
|
63
|
+
fixtures.append(self.path, [later]), original(*args, **kwargs))[1]):
|
|
64
|
+
history = self.validate()
|
|
65
|
+
self.assertEqual((history["cwd"], history["offset"]), (str(self.saved), offset))
|
|
66
|
+
self.assertIsNone(self.validate())
|
|
67
|
+
|
|
68
|
+
def test_replacement_during_directory_read_is_rejected(self):
|
|
69
|
+
self.write([{"type": "turn_context", "payload": {"cwd": str(self.saved)}}])
|
|
70
|
+
replacement = self.path.with_suffix(".replacement")
|
|
71
|
+
replacement.write_bytes(self.path.read_bytes())
|
|
72
|
+
original = handoff.codex_resume_cwd
|
|
73
|
+
with mock.patch.object(handoff, "codex_resume_cwd", side_effect=lambda *args, **kwargs: (
|
|
74
|
+
replacement.replace(self.path), original(*args, **kwargs))[1]):
|
|
75
|
+
self.assertIsNone(self.validate())
|
|
76
|
+
|
|
77
|
+
def test_cross_provider_plan_probes_in_latest_saved_directory(self):
|
|
78
|
+
case, watcher, runner, history, _ = scenario(self, "codex")
|
|
79
|
+
saved = case.tmp / "saved project"
|
|
80
|
+
saved.mkdir()
|
|
81
|
+
fixtures.append(history, [{"type": "turn_context", "payload": {"cwd": str(saved)}}])
|
|
82
|
+
plan = cross.plan_fallback(watcher, watcher.tracker.pending, "")
|
|
83
|
+
self.assertIsNotNone(plan)
|
|
84
|
+
self.assertEqual(plan["history"]["cwd"], str(saved))
|
|
85
|
+
self.assertEqual(runner.target_probes[-1]["cwd"], str(saved))
|
|
86
|
+
self.assertTrue(cross.revalidate(watcher, plan))
|
|
87
|
+
fixtures.append(history, [{"type": "turn_context", "payload": {"cwd": str(case.work)}}])
|
|
88
|
+
self.assertFalse(cross.revalidate(watcher, plan))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
unittest.main()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Read only a bound Codex thread's saved cwd, even across chunk and restart boundaries."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest import mock
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
sys.path.insert(0, str(ROOT / "lib"))
|
|
12
|
+
from codex_resume_cwd import codex_resume_cwd # noqa: E402
|
|
13
|
+
import session_settings # noqa: E402
|
|
14
|
+
|
|
15
|
+
SID = "01a13956-95c9-7d41-9a8b-b3e5ff79d3b2"
|
|
16
|
+
OTHER = "0aaaaaaa-0000-7000-8000-000000000000"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def line(kind, **payload):
|
|
20
|
+
return json.dumps({"type": kind, "payload": payload}, ensure_ascii=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CodexCwdReaderTests(unittest.TestCase):
|
|
24
|
+
def setUp(self):
|
|
25
|
+
directory = tempfile.TemporaryDirectory(prefix="codex-cwd-reader-")
|
|
26
|
+
self.addCleanup(directory.cleanup)
|
|
27
|
+
self.path = Path(directory.name) / "rollout.jsonl"
|
|
28
|
+
self.meta = line("session_meta", id=SID, cwd="/initial project")
|
|
29
|
+
|
|
30
|
+
def write(self, records):
|
|
31
|
+
self.path.write_text("\n".join(records) + "\n", encoding="utf-8")
|
|
32
|
+
return self.path
|
|
33
|
+
|
|
34
|
+
def test_bound_id_is_checked_before_any_saved_context(self):
|
|
35
|
+
path = self.write([self.meta, line("turn_context", cwd="/latest")])
|
|
36
|
+
self.assertEqual(codex_resume_cwd(path, SID), "/latest")
|
|
37
|
+
self.assertEqual(codex_resume_cwd(path), "/latest")
|
|
38
|
+
self.assertIsNone(codex_resume_cwd(path, OTHER))
|
|
39
|
+
boundary = path.stat().st_size
|
|
40
|
+
with path.open("a", encoding="utf-8") as output:
|
|
41
|
+
output.write(line("turn_context", cwd="/after-snapshot") + "\n")
|
|
42
|
+
self.assertEqual(codex_resume_cwd(path, SID), "/after-snapshot")
|
|
43
|
+
self.assertEqual(codex_resume_cwd(path, SID, upto=boundary), "/latest")
|
|
44
|
+
|
|
45
|
+
def test_missing_or_unreadable_history_has_no_directory(self):
|
|
46
|
+
for path in (None, self.path, self.path.parent):
|
|
47
|
+
with self.subTest(path=path):
|
|
48
|
+
self.assertIsNone(codex_resume_cwd(path, SID))
|
|
49
|
+
self.write([self.meta])
|
|
50
|
+
with mock.patch("builtins.open", side_effect=PermissionError("denied")):
|
|
51
|
+
self.assertIsNone(codex_resume_cwd(self.path, SID))
|
|
52
|
+
|
|
53
|
+
def test_invalid_metadata_cannot_bind_a_later_context(self):
|
|
54
|
+
bad = ["{", "[]", "null", json.dumps({"type": "session_meta", "payload": []}),
|
|
55
|
+
line("session_meta", id=None, cwd="/initial"),
|
|
56
|
+
line("turn_context", id=SID, cwd="/initial")]
|
|
57
|
+
for first in bad:
|
|
58
|
+
with self.subTest(first=first):
|
|
59
|
+
path = self.write([first, line("turn_context", cwd="/latest")])
|
|
60
|
+
self.assertIsNone(codex_resume_cwd(path, SID))
|
|
61
|
+
|
|
62
|
+
def test_malformed_and_invalid_later_records_preserve_last_valid_cwd(self):
|
|
63
|
+
bad = ["{", '[]', '{"payload":{"cwd":',
|
|
64
|
+
json.dumps({"type": "turn_context", "payload": None}),
|
|
65
|
+
line("event_msg", type="thread_settings_applied", thread_settings=[])]
|
|
66
|
+
bad.extend(line("turn_context", cwd=value)
|
|
67
|
+
for value in (None, 7, {}, "relative", "/bad\npath", "/bad\rpath", "/bad\0path"))
|
|
68
|
+
path = self.write([self.meta, line("turn_context", cwd="/saved ü")] + bad)
|
|
69
|
+
self.assertEqual(codex_resume_cwd(path, SID), "/saved ü")
|
|
70
|
+
|
|
71
|
+
def test_foreign_settings_and_subagent_context_do_not_change_main_cwd(self):
|
|
72
|
+
path = self.write([
|
|
73
|
+
self.meta,
|
|
74
|
+
line("event_msg", type="thread_settings_applied", thread_id=OTHER,
|
|
75
|
+
thread_settings={"cwd": "/foreign"}),
|
|
76
|
+
line("turn_context", thread_id=OTHER, cwd="/foreign-context"),
|
|
77
|
+
line("turn_context", turn_id="child", root_turn_id="parent", cwd="/subagent"),
|
|
78
|
+
line("response_item", type="function_call", cwd="/tool", arguments='{"cwd":"/quoted"}'),
|
|
79
|
+
line("session_meta", id=OTHER, cwd="/copied-parent"),
|
|
80
|
+
])
|
|
81
|
+
self.assertEqual(codex_resume_cwd(path, SID), "/initial project")
|
|
82
|
+
|
|
83
|
+
def test_reverse_chunks_reassemble_long_records_and_keep_latest_settings(self):
|
|
84
|
+
saved = "/saved " + "é" * 200
|
|
85
|
+
path = self.write([
|
|
86
|
+
self.meta, line("turn_context", cwd="/older"),
|
|
87
|
+
line("event_msg", type="thread_settings_applied", thread_id=SID,
|
|
88
|
+
thread_settings={"cwd": saved}),
|
|
89
|
+
line("response_item", type="message", text="padding" * 100),
|
|
90
|
+
])
|
|
91
|
+
with mock.patch.object(session_settings, "CHUNK", 127):
|
|
92
|
+
self.assertEqual(codex_resume_cwd(path, SID), saved)
|
|
93
|
+
|
|
94
|
+
def test_initial_metadata_remains_fallback_beyond_reverse_scan_limit(self):
|
|
95
|
+
large_meta = line("session_meta", id=SID, cwd="/initial project", base_instructions="x" * (1 << 20))
|
|
96
|
+
path = self.write([large_meta, line("session_meta", id=OTHER, cwd="/copied-parent"),
|
|
97
|
+
line("response_item", type="message", text="padding" * 100)])
|
|
98
|
+
self.assertEqual(codex_resume_cwd(path, SID), "/initial project")
|
|
99
|
+
with mock.patch.object(session_settings, "CHUNK", 128), \
|
|
100
|
+
mock.patch.object(session_settings, "SCAN_LIMIT", 128):
|
|
101
|
+
self.assertEqual(codex_resume_cwd(path, SID), "/initial project")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
unittest.main()
|