cdx-manager 0.10.0 → 0.11.0
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 -8
- package/bin/cdx +2 -11
- package/changelogs/CHANGELOGS_0_11_0.md +51 -0
- package/checksums/release-archives.json +4 -0
- package/install.ps1 +12 -19
- package/install.sh +6 -29
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +17 -33
- package/src/cli.py +5 -36
- package/src/cli_args.py +14 -12
- package/src/cli_commands.py +30 -17
- package/src/cli_helpers.py +26 -38
- package/src/cli_view.py +5 -27
- package/src/codex_usage.py +14 -8
- package/src/notify.py +6 -10
- package/src/provider_runtime.py +58 -14
- package/src/session_service.py +131 -57
- package/src/session_store.py +2 -2
- package/src/status_source.py +47 -25
package/src/cli_helpers.py
CHANGED
|
@@ -57,14 +57,6 @@ def _write_json(ctx, payload):
|
|
|
57
57
|
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def _update_notice_warning(ctx):
|
|
61
|
-
notices = ctx.get("update_notices") or []
|
|
62
|
-
if not notices:
|
|
63
|
-
return None
|
|
64
|
-
warnings = _update_notice_warnings(ctx)
|
|
65
|
-
return warnings[0] if warnings else None
|
|
66
|
-
|
|
67
|
-
|
|
68
60
|
def _update_notice_warnings(ctx):
|
|
69
61
|
warnings = []
|
|
70
62
|
for notice in ctx.get("update_notices") or []:
|
|
@@ -118,7 +110,7 @@ def _format_bytes(value):
|
|
|
118
110
|
amount /= 1024
|
|
119
111
|
if unit == "B":
|
|
120
112
|
return f"{int(amount)} B"
|
|
121
|
-
return f"{amount:.1f} {unit}"
|
|
113
|
+
return f"{int(amount)} {unit}" if amount.is_integer() else f"{amount:.1f} {unit}"
|
|
122
114
|
|
|
123
115
|
|
|
124
116
|
def _format_export_report(result):
|
|
@@ -157,39 +149,16 @@ def _make_export_progress(ctx):
|
|
|
157
149
|
return progress
|
|
158
150
|
|
|
159
151
|
|
|
160
|
-
def
|
|
152
|
+
def _make_session_progress(ctx, start_event, start_message):
|
|
161
153
|
progress_state = {"checked": 0, "total": 0}
|
|
162
154
|
|
|
163
155
|
def progress(event):
|
|
164
156
|
kind = event.get("event")
|
|
165
|
-
if kind ==
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
ctx["out"](f"{_info(
|
|
170
|
-
elif kind == "session_started":
|
|
171
|
-
provider = event.get("provider") or "session"
|
|
172
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
173
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
174
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
175
|
-
progress_state["checked"] += 1
|
|
176
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
177
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
178
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
179
|
-
elif kind == "status_finished":
|
|
180
|
-
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
181
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
182
|
-
return progress
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
def _make_notify_progress(ctx):
|
|
186
|
-
progress_state = {"checked": 0, "total": 0}
|
|
187
|
-
|
|
188
|
-
def progress(event):
|
|
189
|
-
kind = event.get("event")
|
|
190
|
-
if kind == "notify_check_started":
|
|
191
|
-
target = event.get("session_name") or "next ready session"
|
|
192
|
-
ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
|
|
157
|
+
if kind == start_event:
|
|
158
|
+
if kind == "status_started":
|
|
159
|
+
progress_state["checked"] = 0
|
|
160
|
+
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
161
|
+
ctx["out"](f"{_info(start_message(event), ctx['use_color'])}\n")
|
|
193
162
|
elif kind == "status_started":
|
|
194
163
|
progress_state["checked"] = 0
|
|
195
164
|
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
@@ -204,12 +173,31 @@ def _make_notify_progress(ctx):
|
|
|
204
173
|
total = progress_state["total"] or progress_state["checked"]
|
|
205
174
|
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
206
175
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
176
|
+
elif kind == "status_finished":
|
|
177
|
+
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
178
|
+
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
207
179
|
elif kind == "notify_waiting":
|
|
208
180
|
message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
|
|
209
181
|
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
210
182
|
return progress
|
|
211
183
|
|
|
212
184
|
|
|
185
|
+
def _make_status_progress(ctx):
|
|
186
|
+
return _make_session_progress(
|
|
187
|
+
ctx,
|
|
188
|
+
"status_started",
|
|
189
|
+
lambda event: f"Resolving status for {event.get('session_count', 0)} session(s)...",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _make_notify_progress(ctx):
|
|
194
|
+
return _make_session_progress(
|
|
195
|
+
ctx,
|
|
196
|
+
"notify_check_started",
|
|
197
|
+
lambda event: f"Checking notification target: {event.get('session_name') or 'next ready session'}...",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
213
201
|
def _latest_launch_transcript_path(session):
|
|
214
202
|
paths = _list_launch_transcript_paths(session)
|
|
215
203
|
if not paths:
|
package/src/cli_view.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from .cli_helpers import _json_success, _write_json
|
|
1
|
+
from .cli_helpers import _json_failure, _json_success, _update_notice_warnings, _write_json
|
|
2
2
|
from .cli_render import _warn
|
|
3
3
|
from .errors import CdxError
|
|
4
4
|
from .logics_view import (
|
|
@@ -13,31 +13,6 @@ from .update_check import check_logics_manager_for_update
|
|
|
13
13
|
VIEW_USAGE = "Usage: cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--open] [--no-open]"
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
def _update_notice_warnings(notices):
|
|
17
|
-
warnings = []
|
|
18
|
-
for notice in notices or []:
|
|
19
|
-
if not notice:
|
|
20
|
-
continue
|
|
21
|
-
tool = notice.get("tool") or "logics-manager"
|
|
22
|
-
current = notice.get("current_version")
|
|
23
|
-
command = notice.get("update_command")
|
|
24
|
-
message = f"Update available: {tool} {notice['latest_version']}"
|
|
25
|
-
if current:
|
|
26
|
-
message = f"{message} (current {current})"
|
|
27
|
-
if command:
|
|
28
|
-
message = f"{message}. Run: {command}"
|
|
29
|
-
warnings.append({
|
|
30
|
-
"code": f"{tool.replace('-', '_')}_update_available",
|
|
31
|
-
"message": message,
|
|
32
|
-
"tool": tool,
|
|
33
|
-
"latest_version": notice["latest_version"],
|
|
34
|
-
"current_version": current,
|
|
35
|
-
"update_command": command,
|
|
36
|
-
"url": notice.get("url"),
|
|
37
|
-
})
|
|
38
|
-
return warnings
|
|
39
|
-
|
|
40
|
-
|
|
41
16
|
def _logics_manager_update_notice(ctx, env):
|
|
42
17
|
checker = ctx["options"].get("checkLogicsManagerForUpdate") or check_logics_manager_for_update
|
|
43
18
|
return checker(
|
|
@@ -86,9 +61,12 @@ def handle_view(rest, ctx):
|
|
|
86
61
|
update_notice = _logics_manager_update_notice(ctx, env) if executable else None
|
|
87
62
|
failure = None if executable else missing_logics_manager_failure()
|
|
88
63
|
diagnostics = build_viewer_diagnostics(executable, cwd, update_notice=update_notice, failure=failure, extra_args=viewer_args)
|
|
89
|
-
warnings = _update_notice_warnings([update_notice])
|
|
64
|
+
warnings = _update_notice_warnings({"update_notices": [update_notice] if update_notice else []})
|
|
90
65
|
|
|
91
66
|
if json_flag:
|
|
67
|
+
if failure:
|
|
68
|
+
_write_json(ctx, _json_failure("view", "viewer_unavailable", failure["message"], warnings=warnings, viewer=diagnostics))
|
|
69
|
+
return 1
|
|
92
70
|
_write_json(ctx, _json_success("view", "Collected Logics viewer diagnostics", warnings=warnings, viewer=diagnostics))
|
|
93
71
|
return 0
|
|
94
72
|
|
package/src/codex_usage.py
CHANGED
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import queue
|
|
5
5
|
import subprocess
|
|
6
6
|
import threading
|
|
7
|
+
import time
|
|
7
8
|
from datetime import datetime, timezone
|
|
8
9
|
|
|
9
10
|
from .status_source import _is_zero_credit_balance
|
|
@@ -17,7 +18,7 @@ CODEX_AUTH_LOCK_NAME = ".cdx-auth.lock"
|
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
@contextlib.contextmanager
|
|
20
|
-
def codex_auth_lock(auth_home, blocking=False):
|
|
21
|
+
def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_interval_seconds=0.05):
|
|
21
22
|
"""Serialize codex token refreshes per CODEX_HOME.
|
|
22
23
|
|
|
23
24
|
Codex rotates its OAuth refresh_token on refresh and invalidates the old
|
|
@@ -36,13 +37,18 @@ def codex_auth_lock(auth_home, blocking=False):
|
|
|
36
37
|
except OSError:
|
|
37
38
|
yield True
|
|
38
39
|
return
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
fcntl.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
|
|
41
|
+
while True:
|
|
42
|
+
flags = fcntl.LOCK_EX if blocking and timeout_seconds is None else fcntl.LOCK_EX | fcntl.LOCK_NB
|
|
43
|
+
try:
|
|
44
|
+
fcntl.flock(handle, flags)
|
|
45
|
+
break
|
|
46
|
+
except OSError:
|
|
47
|
+
if not blocking or (deadline is not None and time.monotonic() >= deadline):
|
|
48
|
+
handle.close()
|
|
49
|
+
yield False
|
|
50
|
+
return
|
|
51
|
+
time.sleep(retry_interval_seconds)
|
|
46
52
|
try:
|
|
47
53
|
yield True
|
|
48
54
|
finally:
|
package/src/notify.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
3
|
import shlex
|
|
4
|
+
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
import time
|
|
6
7
|
from datetime import datetime, timedelta
|
|
@@ -165,14 +166,14 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
|
|
|
165
166
|
env = env or os.environ
|
|
166
167
|
if sys.platform == "win32":
|
|
167
168
|
_send_windows_notification(title, message, spawn_sync, env)
|
|
168
|
-
elif
|
|
169
|
+
elif shutil.which("osascript", path=env.get("PATH")):
|
|
169
170
|
script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
|
|
170
171
|
_run_notification_command(
|
|
171
172
|
["osascript", "-e", script],
|
|
172
173
|
spawn_sync,
|
|
173
174
|
env,
|
|
174
175
|
)
|
|
175
|
-
elif
|
|
176
|
+
elif shutil.which("notify-send", path=env.get("PATH")):
|
|
176
177
|
_run_notification_command(
|
|
177
178
|
["notify-send", str(title), str(message)],
|
|
178
179
|
spawn_sync,
|
|
@@ -209,11 +210,6 @@ def _escape_powershell(value):
|
|
|
209
210
|
return str(value).replace("'", "''")
|
|
210
211
|
|
|
211
212
|
|
|
212
|
-
def shutil_which(command, env):
|
|
213
|
-
import shutil
|
|
214
|
-
return shutil.which(command, path=env.get("PATH"))
|
|
215
|
-
|
|
216
|
-
|
|
217
213
|
def _escape_applescript(value):
|
|
218
214
|
return str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
219
215
|
|
|
@@ -247,7 +243,7 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
|
|
|
247
243
|
|
|
248
244
|
|
|
249
245
|
def _scheduled_notify_argv(parsed, env):
|
|
250
|
-
executable = env.get("CDX_BIN") or
|
|
246
|
+
executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
|
|
251
247
|
argv = [executable, "notify"]
|
|
252
248
|
if parsed["mode"] == "at-reset":
|
|
253
249
|
argv.append(parsed["name"])
|
|
@@ -331,7 +327,7 @@ def _launchd_plist(label, script_path, target):
|
|
|
331
327
|
def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
332
328
|
unit = _schedule_id("cdx-manager-notify", parsed, event)
|
|
333
329
|
target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
|
|
334
|
-
if
|
|
330
|
+
if shutil.which("systemd-run", path=env.get("PATH")):
|
|
335
331
|
calendar = target.strftime("%Y-%m-%d %H:%M:%S")
|
|
336
332
|
result = _run_scheduler_command(
|
|
337
333
|
["systemd-run", "--user", f"--unit={unit}", f"--on-calendar={calendar}", *argv],
|
|
@@ -343,7 +339,7 @@ def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
|
343
339
|
if _scheduler_error_means_exists(result["error"]):
|
|
344
340
|
return {"scheduled": True, "existing": True, "backend": "systemd", "id": unit}
|
|
345
341
|
raise CdxError(f"Failed to schedule notification with systemd-run: {result['error']}")
|
|
346
|
-
if
|
|
342
|
+
if shutil.which("at", path=env.get("PATH")):
|
|
347
343
|
at_time = target.strftime("%Y%m%d%H%M.%S")
|
|
348
344
|
command = " ".join(shlex.quote(str(part)) for part in argv)
|
|
349
345
|
result = _run_scheduler_command(["at", "-t", at_time], spawn_sync, env, input_text=f"{command}\n")
|
package/src/provider_runtime.py
CHANGED
|
@@ -16,6 +16,11 @@ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVI
|
|
|
16
16
|
from .errors import CdxError
|
|
17
17
|
|
|
18
18
|
LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
19
|
+
AUTH_PROBE_TIMEOUT_SECONDS = 15
|
|
20
|
+
CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
|
|
21
|
+
AUTH_PROBE_AUTHENTICATED = "authenticated"
|
|
22
|
+
AUTH_PROBE_LOGGED_OUT = "logged_out"
|
|
23
|
+
AUTH_PROBE_DEGRADED = "degraded"
|
|
19
24
|
REASONING_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
|
|
20
25
|
RTK_PROMPT = (
|
|
21
26
|
"When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
|
|
@@ -173,12 +178,15 @@ def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
|
|
|
173
178
|
"live_error": None,
|
|
174
179
|
}
|
|
175
180
|
try:
|
|
176
|
-
|
|
181
|
+
status = _probe_provider_auth_status(
|
|
177
182
|
session,
|
|
178
183
|
spawn_sync=spawn_sync,
|
|
179
184
|
env_override=env_override,
|
|
180
185
|
trust_local_credentials=False,
|
|
181
|
-
)
|
|
186
|
+
)
|
|
187
|
+
result["live_status"] = status
|
|
188
|
+
if status == AUTH_PROBE_DEGRADED:
|
|
189
|
+
result["live_error"] = _auth_probe_degraded_message(session)
|
|
182
190
|
except CdxError as error:
|
|
183
191
|
result["live_status"] = "error"
|
|
184
192
|
result["live_error"] = str(error)
|
|
@@ -899,14 +907,21 @@ def _resolve_command(command, env=None):
|
|
|
899
907
|
return shutil.which(command, path=env.get("PATH")) or command
|
|
900
908
|
|
|
901
909
|
|
|
902
|
-
def
|
|
910
|
+
def _auth_probe_degraded_message(session):
|
|
911
|
+
return (
|
|
912
|
+
f"Auth probe timed out after {AUTH_PROBE_TIMEOUT_SECONDS}s for session {session.get('name') or 'unknown'}; "
|
|
913
|
+
"authentication status is degraded."
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _probe_provider_auth_status(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
|
|
903
918
|
spawn_sync = spawn_sync or subprocess.run
|
|
904
919
|
spec = _build_login_status_spec(session, env_override)
|
|
905
920
|
if trust_local_credentials:
|
|
906
921
|
if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
|
|
907
|
-
return
|
|
922
|
+
return AUTH_PROBE_AUTHENTICATED
|
|
908
923
|
if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
|
|
909
|
-
return
|
|
924
|
+
return AUTH_PROBE_AUTHENTICATED
|
|
910
925
|
try:
|
|
911
926
|
if spawn_sync is subprocess.run:
|
|
912
927
|
command = _resolve_command(spec["command"], spec["env"])
|
|
@@ -914,19 +929,33 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
|
|
|
914
929
|
[command] + spec["args"],
|
|
915
930
|
env=spec["env"],
|
|
916
931
|
capture_output=True, text=True,
|
|
932
|
+
timeout=AUTH_PROBE_TIMEOUT_SECONDS,
|
|
917
933
|
)
|
|
918
934
|
output = (result.stdout or "") + (result.stderr or "")
|
|
919
935
|
else:
|
|
920
936
|
result = spawn_sync(spec["command"], spec["args"], spec)
|
|
921
937
|
error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
|
|
922
938
|
if error:
|
|
939
|
+
if isinstance(error, subprocess.TimeoutExpired):
|
|
940
|
+
raise error
|
|
923
941
|
raise _format_probe_failure(session, spec, error)
|
|
924
942
|
stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
|
|
925
943
|
stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
|
|
926
944
|
output = (stdout or "") + (stderr or "")
|
|
945
|
+
except subprocess.TimeoutExpired:
|
|
946
|
+
return AUTH_PROBE_DEGRADED
|
|
927
947
|
except OSError as error:
|
|
928
948
|
raise _format_probe_failure(session, spec, error) from error
|
|
929
|
-
return spec["parser"](output)
|
|
949
|
+
return AUTH_PROBE_AUTHENTICATED if spec["parser"](output) else AUTH_PROBE_LOGGED_OUT
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
|
|
953
|
+
return _probe_provider_auth_status(
|
|
954
|
+
session,
|
|
955
|
+
spawn_sync=spawn_sync,
|
|
956
|
+
env_override=env_override,
|
|
957
|
+
trust_local_credentials=trust_local_credentials,
|
|
958
|
+
) == AUTH_PROBE_AUTHENTICATED
|
|
930
959
|
|
|
931
960
|
|
|
932
961
|
def _signal_exit_code(sig):
|
|
@@ -953,10 +982,15 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
|
|
|
953
982
|
lifecycle_callback=lifecycle_callback)
|
|
954
983
|
if session.get("provider") != PROVIDER_CODEX:
|
|
955
984
|
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
956
|
-
# Hold the per-CODEX_HOME lock for the whole session so
|
|
957
|
-
#
|
|
958
|
-
|
|
959
|
-
|
|
985
|
+
# Hold the per-CODEX_HOME lock for the whole session so concurrent Codex
|
|
986
|
+
# processes cannot rotate the refresh_token under each other.
|
|
987
|
+
with codex_auth_lock(
|
|
988
|
+
_get_auth_home(session),
|
|
989
|
+
blocking=True,
|
|
990
|
+
timeout_seconds=CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS,
|
|
991
|
+
) as acquired:
|
|
992
|
+
if not acquired:
|
|
993
|
+
raise CdxError(f"Timed out waiting for Codex auth lock for session {session['name']}.")
|
|
960
994
|
return _run_interactive_provider_command_impl(session, action, **kwargs)
|
|
961
995
|
|
|
962
996
|
|
|
@@ -1114,16 +1148,26 @@ def _ensure_session_authentication(session, service, spawn=None, spawn_sync=None
|
|
|
1114
1148
|
raise CdxError(
|
|
1115
1149
|
f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
|
|
1116
1150
|
)
|
|
1117
|
-
|
|
1151
|
+
auth_status = _probe_provider_auth_status(
|
|
1118
1152
|
session,
|
|
1119
1153
|
spawn_sync=spawn_sync,
|
|
1120
1154
|
env_override=env_override,
|
|
1121
1155
|
trust_local_credentials=trust_local_credentials,
|
|
1122
1156
|
)
|
|
1123
|
-
if
|
|
1124
|
-
return {"authenticated": True, "checked": True}
|
|
1157
|
+
if auth_status == AUTH_PROBE_AUTHENTICATED:
|
|
1158
|
+
return {"authenticated": True, "checked": True, "status": auth_status}
|
|
1159
|
+
if auth_status == AUTH_PROBE_DEGRADED:
|
|
1160
|
+
result = {
|
|
1161
|
+
"authenticated": False,
|
|
1162
|
+
"checked": True,
|
|
1163
|
+
"status": auth_status,
|
|
1164
|
+
"error": _auth_probe_degraded_message(session),
|
|
1165
|
+
}
|
|
1166
|
+
if behavior == "probe-only":
|
|
1167
|
+
return result
|
|
1168
|
+
raise CdxError(f"{result['error']} Retry, or run: cdx login {session['name']}")
|
|
1125
1169
|
if behavior == "probe-only":
|
|
1126
|
-
return {"authenticated": False, "checked": True}
|
|
1170
|
+
return {"authenticated": False, "checked": True, "status": auth_status}
|
|
1127
1171
|
if behavior == "launch":
|
|
1128
1172
|
raise CdxError(
|
|
1129
1173
|
f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
|
package/src/session_service.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import binascii
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
@@ -901,6 +902,11 @@ def create_session_service(options=None):
|
|
|
901
902
|
# lock; a pre-lock snapshot loses a concurrent setting change.
|
|
902
903
|
current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
|
|
903
904
|
launch = {**current, **updates}
|
|
905
|
+
if "power" in updates:
|
|
906
|
+
launch.pop("reasoning_effort", None)
|
|
907
|
+
launch.pop("reasoningEffort", None)
|
|
908
|
+
if "reasoning_effort" in updates:
|
|
909
|
+
launch.pop("power", None)
|
|
904
910
|
explicit_power = "power" in updates or "reasoning_effort" in updates
|
|
905
911
|
if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
|
|
906
912
|
launch["fast"] = False
|
|
@@ -923,7 +929,7 @@ def create_session_service(options=None):
|
|
|
923
929
|
raise CdxError(f"Unknown session: {name}")
|
|
924
930
|
if not keys:
|
|
925
931
|
raise CdxError("At least one launch setting is required.")
|
|
926
|
-
allowed = {"power", "permission", "fast", "rtk", "logics", "model", "priority"}
|
|
932
|
+
allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
|
|
927
933
|
unknown = [key for key in keys if key not in allowed]
|
|
928
934
|
if unknown:
|
|
929
935
|
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
@@ -1216,7 +1222,12 @@ def create_session_service(options=None):
|
|
|
1216
1222
|
)] = s
|
|
1217
1223
|
for future in as_completed(futures):
|
|
1218
1224
|
s = futures[future]
|
|
1219
|
-
|
|
1225
|
+
try:
|
|
1226
|
+
resolved = future.result()
|
|
1227
|
+
except CdxError as error:
|
|
1228
|
+
if not str(error).startswith("Unknown session:"):
|
|
1229
|
+
raise
|
|
1230
|
+
continue
|
|
1220
1231
|
resolved_by_name[s["name"]] = resolved
|
|
1221
1232
|
if progress_callback:
|
|
1222
1233
|
progress_callback({
|
|
@@ -1225,7 +1236,7 @@ def create_session_service(options=None):
|
|
|
1225
1236
|
"has_status": bool(resolved.get("lastStatus")),
|
|
1226
1237
|
"cache_hit": cache_hits[s["name"]],
|
|
1227
1238
|
})
|
|
1228
|
-
resolved = [resolved_by_name[s["name"]] for s in sessions]
|
|
1239
|
+
resolved = [resolved_by_name[s["name"]] for s in sessions if s["name"] in resolved_by_name]
|
|
1229
1240
|
|
|
1230
1241
|
def sort_key(s):
|
|
1231
1242
|
at = s.get("lastStatusAt") or ""
|
|
@@ -1336,7 +1347,52 @@ def create_session_service(options=None):
|
|
|
1336
1347
|
"bundle_size_bytes": len(bundle_bytes),
|
|
1337
1348
|
}
|
|
1338
1349
|
|
|
1339
|
-
def
|
|
1350
|
+
def _validate_import_profile_files(payload, imported_sessions):
|
|
1351
|
+
profiles = payload.get("profiles") or {}
|
|
1352
|
+
if not isinstance(profiles, dict):
|
|
1353
|
+
raise CdxError("Bundle contains invalid profile data.")
|
|
1354
|
+
decoded_profiles = {}
|
|
1355
|
+
for session_payload in imported_sessions:
|
|
1356
|
+
name = session_payload["name"]
|
|
1357
|
+
files = []
|
|
1358
|
+
profile_items = profiles.get(name, [])
|
|
1359
|
+
if not isinstance(profile_items, list):
|
|
1360
|
+
raise CdxError(f"Bundle contains invalid profile data for session {name}.")
|
|
1361
|
+
for item in profile_items:
|
|
1362
|
+
if not isinstance(item, dict):
|
|
1363
|
+
raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
|
|
1364
|
+
rel_path = _safe_relpath(item.get("path"))
|
|
1365
|
+
data_b64 = item.get("data_b64")
|
|
1366
|
+
if not isinstance(data_b64, str):
|
|
1367
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
|
|
1368
|
+
try:
|
|
1369
|
+
content = base64.b64decode(data_b64.encode("ascii"), validate=True)
|
|
1370
|
+
except (binascii.Error, UnicodeEncodeError) as error:
|
|
1371
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
|
|
1372
|
+
files.append({"path": rel_path, "content": content})
|
|
1373
|
+
decoded_profiles[name] = files
|
|
1374
|
+
return decoded_profiles
|
|
1375
|
+
|
|
1376
|
+
def _restore_import_backup(name, backup_root, session_root, old_record, old_state):
|
|
1377
|
+
if os.path.exists(session_root):
|
|
1378
|
+
remove_tree(session_root, ignore_errors=True)
|
|
1379
|
+
if backup_root and os.path.exists(backup_root):
|
|
1380
|
+
os.rename(backup_root, session_root)
|
|
1381
|
+
if old_record:
|
|
1382
|
+
store["replace_session"](name, old_record)
|
|
1383
|
+
if old_state is not None:
|
|
1384
|
+
store["write_session_state"](name, old_state)
|
|
1385
|
+
else:
|
|
1386
|
+
store["remove_session"](name)
|
|
1387
|
+
|
|
1388
|
+
def import_bundle(
|
|
1389
|
+
file_path,
|
|
1390
|
+
passphrase=None,
|
|
1391
|
+
session_names=None,
|
|
1392
|
+
force=False,
|
|
1393
|
+
merge=False,
|
|
1394
|
+
allow_authless_force=False,
|
|
1395
|
+
):
|
|
1340
1396
|
if not file_path or not os.path.isfile(file_path):
|
|
1341
1397
|
raise CdxError(f"Bundle file not found: {file_path}")
|
|
1342
1398
|
with open(file_path, "rb") as handle:
|
|
@@ -1358,73 +1414,91 @@ def create_session_service(options=None):
|
|
|
1358
1414
|
conflicts = [name for name in names if name in existing]
|
|
1359
1415
|
if conflicts and not force and not merge:
|
|
1360
1416
|
raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
|
|
1417
|
+
if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
|
|
1418
|
+
raise CdxError(
|
|
1419
|
+
"Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
|
|
1420
|
+
"Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
for session_payload in imported_sessions:
|
|
1424
|
+
_validate_new_session_name(session_payload["name"])
|
|
1425
|
+
_normalize_provider(session_payload["provider"])
|
|
1426
|
+
decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
|
|
1361
1427
|
|
|
1362
1428
|
for session_payload in imported_sessions:
|
|
1363
1429
|
name = session_payload["name"]
|
|
1364
|
-
_validate_new_session_name(name)
|
|
1365
1430
|
provider = _normalize_provider(session_payload["provider"])
|
|
1366
1431
|
is_existing = name in existing
|
|
1367
1432
|
|
|
1368
|
-
if is_existing and force:
|
|
1369
|
-
remove_session(name)
|
|
1370
|
-
is_existing = False
|
|
1371
|
-
|
|
1372
1433
|
session_root = _get_session_root(name)
|
|
1373
1434
|
auth_home = _get_session_auth_home(name, provider)
|
|
1374
1435
|
_ensure_private_dir(base_dir)
|
|
1375
1436
|
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
1437
|
+
backup_root = None
|
|
1438
|
+
old_record = None
|
|
1439
|
+
old_state = None
|
|
1440
|
+
if is_existing and force:
|
|
1441
|
+
old_record = store["get_session"](name)
|
|
1442
|
+
old_state = store["read_session_state"](name)
|
|
1443
|
+
if os.path.exists(session_root):
|
|
1444
|
+
backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
1445
|
+
os.rmdir(backup_root)
|
|
1446
|
+
os.rename(session_root, backup_root)
|
|
1447
|
+
is_existing = False
|
|
1376
1448
|
_ensure_private_dir(session_root)
|
|
1377
1449
|
_ensure_private_dir(auth_home)
|
|
1378
1450
|
|
|
1379
1451
|
existing_state_before = None
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1452
|
+
try:
|
|
1453
|
+
if is_existing and merge:
|
|
1454
|
+
existing_record = store["get_session"](name) or {}
|
|
1455
|
+
# replace_session resets the state file to defaults, so the
|
|
1456
|
+
# local state must be captured before it runs.
|
|
1457
|
+
existing_state_before = store["read_session_state"](name) or {}
|
|
1458
|
+
bundle_record = {
|
|
1459
|
+
**session_payload,
|
|
1460
|
+
"provider": provider,
|
|
1461
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
1462
|
+
"sessionRoot": session_root,
|
|
1463
|
+
"authHome": auth_home,
|
|
1464
|
+
}
|
|
1465
|
+
# Existing values take precedence; bundle fills in missing keys only.
|
|
1466
|
+
merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
|
|
1467
|
+
merged_record["sessionRoot"] = session_root
|
|
1468
|
+
merged_record["authHome"] = auth_home
|
|
1469
|
+
store["replace_session"](name, merged_record)
|
|
1470
|
+
else:
|
|
1471
|
+
session_record = {
|
|
1472
|
+
**session_payload,
|
|
1473
|
+
"provider": provider,
|
|
1474
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
1475
|
+
"sessionRoot": session_root,
|
|
1476
|
+
"authHome": auth_home,
|
|
1477
|
+
}
|
|
1478
|
+
store["replace_session"](name, session_record)
|
|
1479
|
+
|
|
1480
|
+
state = (payload.get("states") or {}).get(name)
|
|
1481
|
+
if is_existing and merge:
|
|
1482
|
+
merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
|
|
1483
|
+
if merged_state:
|
|
1484
|
+
store["write_session_state"](name, merged_state)
|
|
1485
|
+
elif state is not None:
|
|
1486
|
+
store["write_session_state"](name, state)
|
|
1487
|
+
|
|
1488
|
+
for item in decoded_profiles.get(name, []):
|
|
1489
|
+
dest_path = os.path.join(session_root, item["path"])
|
|
1490
|
+
# In merge mode, skip files that already exist locally.
|
|
1491
|
+
if is_existing and merge and os.path.exists(dest_path):
|
|
1492
|
+
continue
|
|
1493
|
+
_ensure_private_dir(os.path.dirname(dest_path))
|
|
1494
|
+
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
1495
|
+
atomic_write(dest_path, item["content"], mode=0o600)
|
|
1496
|
+
except Exception:
|
|
1497
|
+
_restore_import_backup(name, backup_root, session_root, old_record, old_state)
|
|
1498
|
+
raise
|
|
1499
|
+
finally:
|
|
1500
|
+
if backup_root and os.path.exists(backup_root):
|
|
1501
|
+
remove_tree(backup_root, ignore_errors=True)
|
|
1428
1502
|
|
|
1429
1503
|
return {
|
|
1430
1504
|
"path": file_path,
|