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.
@@ -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 _make_status_progress(ctx):
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 == "status_started":
166
- progress_state["checked"] = 0
167
- progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
168
- message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
169
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
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
 
@@ -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
- flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
40
- try:
41
- fcntl.flock(handle, flags)
42
- except OSError:
43
- handle.close()
44
- yield False
45
- return
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 shutil_which("osascript", env):
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 shutil_which("notify-send", env):
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 shutil_which("cdx", env) or "cdx"
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 shutil_which("systemd-run", env):
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 shutil_which("at", env):
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")
@@ -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
- result["live_status"] = "authenticated" if _probe_provider_auth(
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
- ) else "logged_out"
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 _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
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 True
922
+ return AUTH_PROBE_AUTHENTICATED
908
923
  if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
909
- return True
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 a concurrent status
957
- # probe backs off instead of rotating the refresh_token mid-run (which logs
958
- # the session out). ponytail: non-blocking, so launch never waits on a probe.
959
- with codex_auth_lock(_get_auth_home(session)):
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
- is_authenticated = _probe_provider_auth(
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 is_authenticated:
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']}"
@@ -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
- resolved = future.result()
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 import_bundle(file_path, passphrase=None, session_names=None, force=False, merge=False):
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
- if is_existing and merge:
1381
- existing_record = store["get_session"](name) or {}
1382
- # replace_session resets the state file to defaults, so the
1383
- # local state must be captured before it runs.
1384
- existing_state_before = store["read_session_state"](name) or {}
1385
- bundle_record = {
1386
- **session_payload,
1387
- "provider": provider,
1388
- "enabled": session_payload.get("enabled", True) is not False,
1389
- "sessionRoot": session_root,
1390
- "authHome": auth_home,
1391
- }
1392
- # Existing values take precedence; bundle fills in missing keys only.
1393
- merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
1394
- merged_record["sessionRoot"] = session_root
1395
- merged_record["authHome"] = auth_home
1396
- store["replace_session"](name, merged_record)
1397
- else:
1398
- session_record = {
1399
- **session_payload,
1400
- "provider": provider,
1401
- "enabled": session_payload.get("enabled", True) is not False,
1402
- "sessionRoot": session_root,
1403
- "authHome": auth_home,
1404
- }
1405
- store["replace_session"](name, session_record)
1406
-
1407
- state = (payload.get("states") or {}).get(name)
1408
- if is_existing and merge:
1409
- merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
1410
- if merged_state:
1411
- store["write_session_state"](name, merged_state)
1412
- elif state is not None:
1413
- store["write_session_state"](name, state)
1414
-
1415
- for item in (payload.get("profiles") or {}).get(name, []):
1416
- rel_path = _safe_relpath(item.get("path"))
1417
- try:
1418
- content = base64.b64decode(item.get("data_b64", "").encode("ascii"))
1419
- except (AttributeError, ValueError, UnicodeEncodeError) as error:
1420
- raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
1421
- dest_path = os.path.join(session_root, rel_path)
1422
- # In merge mode, skip files that already exist locally.
1423
- if is_existing and merge and os.path.exists(dest_path):
1424
- continue
1425
- _ensure_private_dir(os.path.dirname(dest_path))
1426
- # Decrypted credentials: 0o600 from creation, no umask window.
1427
- atomic_write(dest_path, content, mode=0o600)
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,