cdx-manager 0.3.4 → 0.4.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.
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import getpass
2
3
  import json
3
4
  import os
4
5
  from datetime import datetime
@@ -6,10 +7,9 @@ from datetime import datetime
6
7
  from .claude_refresh import _refresh_claude_sessions
7
8
  from .cli_render import _dim, _info, _success, _warn
8
9
  from .errors import CdxError
9
- from .health import collect_health_report, format_health_report, health_json
10
+ from .health import collect_health_report, format_health_report
10
11
  from .notify import (
11
12
  format_notify_event,
12
- notify_json,
13
13
  parse_notify_args,
14
14
  send_desktop_notification,
15
15
  wait_for_notification_event,
@@ -19,25 +19,30 @@ from .provider_runtime import (
19
19
  _list_launch_transcript_paths,
20
20
  _run_interactive_provider_command,
21
21
  )
22
- from .repair import format_repair_report, repair_health, repair_json
22
+ from .repair import format_repair_report, repair_health
23
+ from .backup_bundle import read_bundle_meta
23
24
  from .status_view import _format_status_detail, _format_status_rows
24
25
 
25
26
 
26
27
  STATUS_USAGE = "Usage: cdx status [--json] [--refresh] | cdx status --small|-s [--refresh] | cdx status <name> [--json] [--refresh]"
27
28
  DOCTOR_USAGE = "Usage: cdx doctor [--json]"
28
29
  REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
30
+ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
31
+ IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
32
+ API_SCHEMA_VERSION = 1
29
33
 
30
34
 
31
35
  def _local_now_iso():
32
36
  return datetime.now().astimezone().isoformat()
33
37
 
34
38
 
35
- def _json_success(action, message, **extra):
39
+ def _json_success(action, message, warnings=None, **extra):
36
40
  payload = {
41
+ "schema_version": API_SCHEMA_VERSION,
37
42
  "ok": True,
38
43
  "action": action,
39
44
  "message": message,
40
- "warnings": [],
45
+ "warnings": warnings or [],
41
46
  }
42
47
  payload.update(extra)
43
48
  return payload
@@ -82,6 +87,142 @@ def _parse_remove_args(args):
82
87
  return {"name": names[0], "force": force}
83
88
 
84
89
 
90
+ def _read_option_value(args, index, usage):
91
+ if index + 1 >= len(args):
92
+ raise CdxError(usage)
93
+ return args[index + 1], index + 2
94
+
95
+
96
+ def _parse_session_names(value):
97
+ if value is None:
98
+ return None
99
+ names = [item.strip() for item in value.split(",") if item.strip()]
100
+ if not names:
101
+ raise CdxError("At least one session name is required in --sessions.")
102
+ return names
103
+
104
+
105
+ def _parse_export_args(args):
106
+ parsed = {
107
+ "file_path": None,
108
+ "include_auth": False,
109
+ "force": False,
110
+ "json": False,
111
+ "session_names": None,
112
+ "passphrase_env": None,
113
+ }
114
+ index = 0
115
+ while index < len(args):
116
+ arg = args[index]
117
+ if arg == "--include-auth":
118
+ parsed["include_auth"] = True
119
+ index += 1
120
+ continue
121
+ if arg == "--force":
122
+ parsed["force"] = True
123
+ index += 1
124
+ continue
125
+ if arg == "--json":
126
+ parsed["json"] = True
127
+ index += 1
128
+ continue
129
+ if arg == "--sessions":
130
+ value, index = _read_option_value(args, index, EXPORT_USAGE)
131
+ parsed["session_names"] = _parse_session_names(value)
132
+ continue
133
+ if arg.startswith("--sessions="):
134
+ parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
135
+ index += 1
136
+ continue
137
+ if arg == "--passphrase-env":
138
+ value, index = _read_option_value(args, index, EXPORT_USAGE)
139
+ parsed["passphrase_env"] = value
140
+ continue
141
+ if arg.startswith("--passphrase-env="):
142
+ parsed["passphrase_env"] = arg.split("=", 1)[1]
143
+ index += 1
144
+ continue
145
+ if arg.startswith("-"):
146
+ raise CdxError(EXPORT_USAGE)
147
+ if parsed["file_path"] is not None:
148
+ raise CdxError(EXPORT_USAGE)
149
+ parsed["file_path"] = arg
150
+ index += 1
151
+
152
+ if not parsed["file_path"]:
153
+ raise CdxError(EXPORT_USAGE)
154
+ if parsed["passphrase_env"] and not parsed["include_auth"]:
155
+ raise CdxError("--passphrase-env requires --include-auth for export.")
156
+ return parsed
157
+
158
+
159
+ def _parse_import_args(args):
160
+ parsed = {
161
+ "file_path": None,
162
+ "force": False,
163
+ "json": False,
164
+ "session_names": None,
165
+ "passphrase_env": None,
166
+ }
167
+ index = 0
168
+ while index < len(args):
169
+ arg = args[index]
170
+ if arg == "--force":
171
+ parsed["force"] = True
172
+ index += 1
173
+ continue
174
+ if arg == "--json":
175
+ parsed["json"] = True
176
+ index += 1
177
+ continue
178
+ if arg == "--sessions":
179
+ value, index = _read_option_value(args, index, IMPORT_USAGE)
180
+ parsed["session_names"] = _parse_session_names(value)
181
+ continue
182
+ if arg.startswith("--sessions="):
183
+ parsed["session_names"] = _parse_session_names(arg.split("=", 1)[1])
184
+ index += 1
185
+ continue
186
+ if arg == "--passphrase-env":
187
+ value, index = _read_option_value(args, index, IMPORT_USAGE)
188
+ parsed["passphrase_env"] = value
189
+ continue
190
+ if arg.startswith("--passphrase-env="):
191
+ parsed["passphrase_env"] = arg.split("=", 1)[1]
192
+ index += 1
193
+ continue
194
+ if arg.startswith("-"):
195
+ raise CdxError(IMPORT_USAGE)
196
+ if parsed["file_path"] is not None:
197
+ raise CdxError(IMPORT_USAGE)
198
+ parsed["file_path"] = arg
199
+ index += 1
200
+
201
+ if not parsed["file_path"]:
202
+ raise CdxError(IMPORT_USAGE)
203
+ return parsed
204
+
205
+
206
+ def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
207
+ env = ctx.get("env", {})
208
+ if env_var:
209
+ passphrase = env.get(env_var)
210
+ if not passphrase:
211
+ raise CdxError(f"Environment variable {env_var} is empty or unset.")
212
+ return passphrase
213
+ if not ctx["stdin_is_tty"]:
214
+ raise CdxError("Encrypted bundle export/import requires an interactive terminal or --passphrase-env.")
215
+ getpass_fn = ctx["options"].get("getpass") or getpass.getpass
216
+ passphrase = getpass_fn(prompt)
217
+ if not passphrase:
218
+ raise CdxError("Bundle passphrase cannot be empty.")
219
+ if confirm:
220
+ confirmation = getpass_fn("Confirm bundle passphrase: ")
221
+ if passphrase != confirmation:
222
+ raise CdxError("Bundle passphrase confirmation does not match.")
223
+ return passphrase
224
+
225
+
85
226
  def _confirm_removal(name):
86
227
  answer = input(f"Remove session {name}? [y/N] ")
87
228
  return answer.strip().lower() in ("y", "yes")
@@ -269,7 +410,7 @@ def handle_doctor(rest, ctx):
269
410
  env=ctx.get("env"),
270
411
  )
271
412
  if json_flag:
272
- ctx["out"](f"{health_json(report)}\n")
413
+ _write_json(ctx, _json_success("doctor", "Collected health report", report=report))
273
414
  else:
274
415
  ctx["out"](f"{format_health_report(report, use_color=ctx['use_color'])}\n")
275
416
  return 0
@@ -291,7 +432,7 @@ def handle_repair(rest, ctx):
291
432
  force=force,
292
433
  )
293
434
  if json_flag:
294
- ctx["out"](f"{repair_json(report)}\n")
435
+ _write_json(ctx, _json_success("repair", "Collected repair report", report=report))
295
436
  else:
296
437
  ctx["out"](f"{format_repair_report(report, use_color=ctx['use_color'])}\n")
297
438
  if dry_run:
@@ -318,7 +459,7 @@ def handle_notify(rest, ctx):
318
459
  now_fn=ctx["options"].get("now"),
319
460
  )
320
461
  if parsed["json"]:
321
- ctx["out"](f"{notify_json(event)}\n")
462
+ _write_json(ctx, _json_success("notify", "Resolved notification event", event=event))
322
463
  else:
323
464
  ctx["out"](f"{format_notify_event(event)}\n")
324
465
  return 0
@@ -349,12 +490,19 @@ def handle_status(rest, ctx):
349
490
  }
350
491
  for item in refresh_result.get("errors", [])
351
492
  ]
493
+ warnings = [
494
+ {
495
+ "code": "claude_refresh_failed",
496
+ "session": item.get("session") or "unknown",
497
+ "message": item.get("error") or "unknown error",
498
+ }
499
+ for item in refresh_errors
500
+ ]
352
501
 
353
502
  rows = ctx["service"]["get_status_rows"]()
354
503
  if len(args) == 0:
355
504
  if json_flag:
356
- ctx["out"](f"{json.dumps(rows, indent=2)}\n")
357
- _write_refresh_warnings(refresh_errors, ctx, stream="err")
505
+ _write_json(ctx, _json_success("status", "Collected session status rows", warnings=warnings, rows=rows))
358
506
  return 0
359
507
  ctx["out"](f"{_format_status_rows(rows, use_color=ctx['use_color'], small=small_flag)}\n")
360
508
  _write_refresh_warnings(refresh_errors, ctx)
@@ -364,8 +512,7 @@ def handle_status(rest, ctx):
364
512
  if not row:
365
513
  raise CdxError(f"Unknown session: {args[0]}")
366
514
  if json_flag:
367
- ctx["out"](f"{json.dumps(row, indent=2)}\n")
368
- _write_refresh_warnings(refresh_errors, ctx, stream="err")
515
+ _write_json(ctx, _json_success("status", f"Collected status for {args[0]}", warnings=warnings, session=row))
369
516
  return 0
370
517
  ctx["out"](f"{_format_status_detail(row, use_color=ctx['use_color'])}\n")
371
518
  _write_refresh_warnings(refresh_errors, ctx)
@@ -380,6 +527,74 @@ def _write_refresh_warnings(refresh_errors, ctx, stream="out"):
380
527
  write(f"{_warn(f'Warning: Claude refresh failed for {session}: {error}', ctx['use_color'])}\n")
381
528
 
382
529
 
530
+ def handle_export(rest, ctx):
531
+ parsed = _parse_export_args(rest)
532
+ passphrase = None
533
+ if parsed["include_auth"]:
534
+ passphrase = _resolve_bundle_passphrase(
535
+ ctx,
536
+ parsed["passphrase_env"],
537
+ "Bundle passphrase: ",
538
+ confirm=True,
539
+ )
540
+ result = ctx["service"]["export_bundle"](
541
+ parsed["file_path"],
542
+ include_auth=parsed["include_auth"],
543
+ session_names=parsed["session_names"],
544
+ passphrase=passphrase,
545
+ force=parsed["force"],
546
+ )
547
+ session_count = len(result["session_names"])
548
+ auth_suffix = " with auth" if result["include_auth"] else ""
549
+ message = f"Exported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} to {result['path']}"
550
+ payload = _json_success(
551
+ "export",
552
+ message,
553
+ bundle=result,
554
+ )
555
+ if parsed["json"]:
556
+ _write_json(ctx, payload)
557
+ return 0
558
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
559
+ return 0
560
+
561
+
562
+ def handle_import(rest, ctx):
563
+ parsed = _parse_import_args(rest)
564
+ passphrase = None
565
+ try:
566
+ with open(parsed["file_path"], "rb") as handle:
567
+ meta = read_bundle_meta(handle.read())
568
+ except OSError as error:
569
+ raise CdxError(f"Bundle file not found: {parsed['file_path']}") from error
570
+ if meta.get("encrypted"):
571
+ passphrase = _resolve_bundle_passphrase(
572
+ ctx,
573
+ parsed["passphrase_env"],
574
+ "Bundle passphrase: ",
575
+ confirm=False,
576
+ )
577
+ result = ctx["service"]["import_bundle"](
578
+ parsed["file_path"],
579
+ passphrase=passphrase,
580
+ session_names=parsed["session_names"],
581
+ force=parsed["force"],
582
+ )
583
+ session_count = len(result["session_names"])
584
+ auth_suffix = " with auth" if result["include_auth"] else ""
585
+ message = f"Imported {session_count} session{'s' if session_count != 1 else ''}{auth_suffix} from {result['path']}"
586
+ payload = _json_success(
587
+ "import",
588
+ message,
589
+ bundle=result,
590
+ )
591
+ if parsed["json"]:
592
+ _write_json(ctx, payload)
593
+ return 0
594
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
595
+ return 0
596
+
597
+
383
598
  def handle_login(rest, ctx):
384
599
  json_flag, args = _parse_json_flag(rest)
385
600
  if len(args) != 1:
@@ -436,6 +651,15 @@ def handle_logout(rest, ctx):
436
651
 
437
652
  def handle_launch(command, ctx):
438
653
  json_flag = "--json" in ctx["options"].get("raw_args", [])
654
+ update_notice = ctx.get("update_notice")
655
+ warnings = []
656
+ if update_notice:
657
+ warnings.append({
658
+ "code": "update_available",
659
+ "message": f"Update available: cdx-manager {update_notice['latest_version']}",
660
+ "latest_version": update_notice["latest_version"],
661
+ "url": update_notice.get("url"),
662
+ })
439
663
  session = ctx["service"]["launch_session"](command)
440
664
  _ensure_session_authentication(
441
665
  session,
@@ -450,6 +674,11 @@ def handle_launch(command, ctx):
450
674
  message = f"Launching {session['provider']} session {session['name']}"
451
675
  if not json_flag:
452
676
  ctx["out"](f"{_info(message, ctx['use_color'])}\n")
677
+ if update_notice:
678
+ text = f"Update available: cdx-manager {update_notice['latest_version']} (current version installed may be older)."
679
+ if update_notice.get("url"):
680
+ text = f"{text} {update_notice['url']}"
681
+ ctx["out"](f"{_warn(text, ctx['use_color'])}\n")
453
682
  if session["provider"] == "codex":
454
683
  if not json_flag:
455
684
  ctx["out"](f"{_dim('Tip: run /status once the Codex session opens.', ctx['use_color'])}\n")
@@ -458,5 +687,5 @@ def handle_launch(command, ctx):
458
687
  signal_emitter=ctx.get("signal_emitter")
459
688
  )
460
689
  if json_flag:
461
- _write_json(ctx, _json_success("launch", message, session=ctx["service"]["get_session"](session["name"])))
690
+ _write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
462
691
  return 0
@@ -2,10 +2,12 @@ import os
2
2
  import shutil
3
3
  import json
4
4
  import base64
5
+ import sys
5
6
  import tempfile
6
7
  from datetime import datetime, timezone
7
8
  from urllib.parse import quote
8
9
 
10
+ from .backup_bundle import decode_bundle, encode_bundle
9
11
  from .config import get_cdx_home
10
12
  from .errors import CdxError
11
13
  from .session_store import create_session_store
@@ -13,12 +15,15 @@ from .status_source import find_latest_status_artifact
13
15
 
14
16
  DEFAULT_PROVIDER = "codex"
15
17
  ALLOWED_PROVIDERS = {"codex", "claude"}
18
+ MAX_SESSION_NAME_LENGTH = 64
16
19
  RESERVED_SESSION_NAMES = {
17
20
  "add",
18
21
  "clean",
19
22
  "cp",
20
23
  "doctor",
24
+ "export",
21
25
  "help",
26
+ "import",
22
27
  "login",
23
28
  "logout",
24
29
  "mv",
@@ -40,10 +45,27 @@ def _encode(name):
40
45
  return quote(name, safe="")
41
46
 
42
47
 
48
+ def _ensure_private_dir(path):
49
+ os.makedirs(path, exist_ok=True)
50
+ if sys.platform == "win32":
51
+ return
52
+ try:
53
+ os.chmod(path, 0o700)
54
+ except OSError:
55
+ pass
56
+
57
+
43
58
  def _local_now_iso():
44
59
  return datetime.now().astimezone().isoformat()
45
60
 
46
61
 
62
+ def _safe_relpath(path):
63
+ normalized = str(path or "").replace("\\", "/").strip("/")
64
+ if not normalized or normalized.startswith("../") or "/../" in f"/{normalized}/":
65
+ raise CdxError("Bundle contains an unsafe file path.")
66
+ return normalized
67
+
68
+
47
69
  def _to_local_iso(value):
48
70
  if not value:
49
71
  return value
@@ -222,15 +244,65 @@ def create_session_service(options=None):
222
244
  def _validate_new_session_name(name):
223
245
  if not name:
224
246
  raise CdxError("Session name is required")
247
+ if str(name) != str(name).strip():
248
+ raise CdxError("Session name cannot start or end with whitespace")
249
+ if len(str(name)) > MAX_SESSION_NAME_LENGTH:
250
+ raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
251
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
252
+ raise CdxError("Session name cannot contain control characters")
225
253
  if name in RESERVED_SESSION_NAMES:
226
254
  raise CdxError(f"Session name is reserved: {name}")
227
255
 
256
+ def _build_export_session_record(session):
257
+ return {
258
+ "name": session["name"],
259
+ "provider": session["provider"],
260
+ "createdAt": session.get("createdAt"),
261
+ "updatedAt": session.get("updatedAt"),
262
+ "lastLaunchedAt": session.get("lastLaunchedAt"),
263
+ "lastStatusAt": session.get("lastStatusAt"),
264
+ "lastStatus": session.get("lastStatus"),
265
+ "auth": session.get("auth"),
266
+ }
267
+
268
+ def _collect_profile_files(session_root):
269
+ excluded_dirs = {"log", "tmp", "cache", "__pycache__", "shell_snapshots"}
270
+ files = []
271
+ if not os.path.isdir(session_root):
272
+ return files
273
+ for dirpath, dirnames, filenames in os.walk(session_root):
274
+ dirnames[:] = [name for name in dirnames if name not in excluded_dirs]
275
+ for filename in filenames:
276
+ full_path = os.path.join(dirpath, filename)
277
+ if not os.path.isfile(full_path):
278
+ continue
279
+ rel_path = os.path.relpath(full_path, session_root)
280
+ with open(full_path, "rb") as handle:
281
+ content = base64.b64encode(handle.read()).decode("ascii")
282
+ files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
283
+ return files
284
+
285
+ def _resolve_session_subset(session_names):
286
+ if not session_names:
287
+ return list_sessions()
288
+ by_name = {session["name"]: session for session in list_sessions()}
289
+ selected = []
290
+ for name in session_names:
291
+ session = by_name.get(name)
292
+ if not session:
293
+ raise CdxError(f"Unknown session: {name}")
294
+ selected.append(session)
295
+ return selected
296
+
228
297
  def create_session(name, provider=DEFAULT_PROVIDER):
229
298
  _validate_new_session_name(name)
230
299
  normalized_provider = _normalize_provider(provider)
231
300
  session_root = _get_session_root(name)
232
301
  auth_home = _get_session_auth_home(name, normalized_provider)
233
- os.makedirs(auth_home, exist_ok=True)
302
+ _ensure_private_dir(base_dir)
303
+ _ensure_private_dir(os.path.join(base_dir, "profiles"))
304
+ _ensure_private_dir(session_root)
305
+ _ensure_private_dir(auth_home)
234
306
  now = _local_now_iso()
235
307
  session = {
236
308
  "name": name,
@@ -514,7 +586,6 @@ def create_session_service(options=None):
514
586
  rows.append({
515
587
  "session_name": s["name"],
516
588
  "provider": s["provider"],
517
- "auth_home": s.get("authHome") or _get_session_auth_home(s["name"], s["provider"]),
518
589
  "remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
519
590
  "remaining_week_pct": status.get("remaining_week_pct") if status else None,
520
591
  "credits": status.get("credits") if status else None,
@@ -543,6 +614,116 @@ def create_session_service(options=None):
543
614
  def get_session_root(name):
544
615
  return _get_session_root(name)
545
616
 
617
+ def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False):
618
+ if not file_path:
619
+ raise CdxError("Export path is required.")
620
+ if os.path.exists(file_path) and not force:
621
+ raise CdxError(f"Export path already exists: {file_path}")
622
+
623
+ sessions = _resolve_session_subset(session_names)
624
+ payload = {
625
+ "schema_version": 1,
626
+ "created_at": _local_now_iso(),
627
+ "include_auth": bool(include_auth),
628
+ "sessions": [],
629
+ "states": {},
630
+ "profiles": {},
631
+ }
632
+ for session in sessions:
633
+ payload["sessions"].append(_build_export_session_record(session))
634
+ state = store["read_session_state"](session["name"])
635
+ if state is not None:
636
+ payload["states"][session["name"]] = state
637
+ if include_auth:
638
+ session_root = session.get("sessionRoot") or _get_session_root(session["name"])
639
+ payload["profiles"][session["name"]] = _collect_profile_files(session_root)
640
+
641
+ bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
642
+ _ensure_private_dir(os.path.dirname(os.path.abspath(file_path)) or ".")
643
+ with open(file_path, "wb") as handle:
644
+ handle.write(bundle_bytes)
645
+ if sys.platform != "win32":
646
+ try:
647
+ os.chmod(file_path, 0o600)
648
+ except OSError:
649
+ pass
650
+ return {
651
+ "path": file_path,
652
+ "include_auth": include_auth,
653
+ "session_names": [session["name"] for session in sessions],
654
+ }
655
+
656
+ def import_bundle(file_path, passphrase=None, session_names=None, force=False):
657
+ if not file_path or not os.path.isfile(file_path):
658
+ raise CdxError(f"Bundle file not found: {file_path}")
659
+ with open(file_path, "rb") as handle:
660
+ decoded = decode_bundle(handle.read(), passphrase=passphrase)
661
+ payload = decoded["payload"]
662
+ imported_sessions = payload.get("sessions") or []
663
+ if payload.get("schema_version") != 1:
664
+ raise CdxError("Unsupported bundle payload schema version.")
665
+
666
+ selected_names = set(session_names or [])
667
+ if selected_names:
668
+ imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
669
+ missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
670
+ if missing_names:
671
+ raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
672
+ names = [item["name"] for item in imported_sessions]
673
+
674
+ existing = {session["name"] for session in list_sessions()}
675
+ conflicts = [name for name in names if name in existing]
676
+ if conflicts and not force:
677
+ raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
678
+
679
+ for session_payload in imported_sessions:
680
+ name = session_payload["name"]
681
+ _validate_new_session_name(name)
682
+ provider = _normalize_provider(session_payload["provider"])
683
+ if name in existing:
684
+ remove_session(name)
685
+
686
+ session_root = _get_session_root(name)
687
+ auth_home = _get_session_auth_home(name, provider)
688
+ _ensure_private_dir(base_dir)
689
+ _ensure_private_dir(os.path.join(base_dir, "profiles"))
690
+ _ensure_private_dir(session_root)
691
+ _ensure_private_dir(auth_home)
692
+
693
+ session_record = {
694
+ **session_payload,
695
+ "provider": provider,
696
+ "sessionRoot": session_root,
697
+ "authHome": auth_home,
698
+ }
699
+ store["replace_session"](name, session_record)
700
+
701
+ state = (payload.get("states") or {}).get(name)
702
+ if state is not None:
703
+ store["write_session_state"](name, state)
704
+
705
+ for item in (payload.get("profiles") or {}).get(name, []):
706
+ rel_path = _safe_relpath(item.get("path"))
707
+ try:
708
+ content = base64.b64decode(item.get("data_b64", "").encode("ascii"))
709
+ except (AttributeError, ValueError, UnicodeEncodeError) as error:
710
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
711
+ dest_path = os.path.join(session_root, rel_path)
712
+ _ensure_private_dir(os.path.dirname(dest_path))
713
+ with open(dest_path, "wb") as handle:
714
+ handle.write(content)
715
+ if sys.platform != "win32":
716
+ try:
717
+ os.chmod(dest_path, 0o600)
718
+ except OSError:
719
+ pass
720
+
721
+ return {
722
+ "path": file_path,
723
+ "session_names": names,
724
+ "include_auth": bool(decoded["meta"].get("include_auth")),
725
+ }
726
+
546
727
  return {
547
728
  "create_session": create_session,
548
729
  "remove_session": remove_session,
@@ -558,6 +739,8 @@ def create_session_service(options=None):
558
739
  "format_list_rows": format_list_rows,
559
740
  "get_session_auth_home": get_session_auth_home,
560
741
  "get_session_root": get_session_root,
742
+ "export_bundle": export_bundle,
743
+ "import_bundle": import_bundle,
561
744
  "base_dir": base_dir,
562
745
  "normalize_provider": _normalize_provider,
563
746
  }
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import sys
3
4
  import tempfile
4
5
  from contextlib import contextmanager
5
6
  from pathlib import Path
@@ -9,6 +10,16 @@ from .errors import CdxError
9
10
 
10
11
  def _ensure_dir(path):
11
12
  Path(path).mkdir(parents=True, exist_ok=True)
13
+ _restrict_dir_permissions(path)
14
+
15
+
16
+ def _restrict_dir_permissions(path):
17
+ if sys.platform == "win32":
18
+ return
19
+ try:
20
+ os.chmod(path, 0o700)
21
+ except OSError:
22
+ pass
12
23
 
13
24
 
14
25
  def _read_json(file_path, fallback):
@@ -119,7 +119,9 @@ def _extract_status_blocks_from_text(text, provider=None, source_ref=None, times
119
119
  r"^To continue this session\b", r"^╰",
120
120
  ]],
121
121
  context_pattern=re.compile(
122
- r"^\s*$|^\s*(?:[│|]\s*)?(?:╭|Visit\b|information\b|Model:|Directory:|Permissions:|Agents\.md:|Account:|Collaboration mode:|Session:)",
122
+ r"^\s*$"
123
+ r"|^\s*[│|](?:\s|[│|])*$"
124
+ r"|^\s*(?:[│|]\s*)?(?:╭|Visit\b|information\b|Model:|Directory:|Permissions:|Agents\.md:|Account:|Collaboration mode:|Session:)",
123
125
  re.I,
124
126
  ),
125
127
  context_stop_patterns=[