cdx-manager 0.12.3 → 0.14.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,88 +1,124 @@
1
- import base64
2
- import binascii
1
+ #!/usr/bin/env python3
2
+ """Session service: session CRUD, launch settings, runtime, auth, and assembly.
3
+
4
+ `create_session_service` returns the interface dict every caller uses; the
5
+ functions behind it live here, or in one of the modules this re-exports from.
6
+
7
+ Why the split stops here. `req_027` measured the coupling between the six
8
+ concern groups by transitive closure and cut only where it was real:
9
+
10
+ group entry lines helpers reached only from it
11
+ status 264 16 -> src/session_status.py
12
+ backup 179 9 -> src/session_backup.py
13
+ crud 235 3 stays
14
+ settings 58 1 stays
15
+ runtime 115 0 stays
16
+ auth 10 0 stays
17
+
18
+ `runtime` and `auth` have **no** helper of their own. Made into modules they
19
+ would be thin wrappers over imports from here, which reads worse than leaving
20
+ them in place; `crud` is the core the others build on. If you are about to
21
+ finish the job by splitting the remaining four, re-measure first - that is the
22
+ evidence that says not to.
23
+
24
+ The `# noqa: F401` markers below are load-bearing: without them ruff removes
25
+ the re-exports as unused and callers importing through this module break.
26
+ `test_session_service_facade_still_exposes_every_name_its_callers_import`
27
+ guards that.
28
+ """
3
29
  import json
4
30
  import os
5
31
  import shutil
6
- import sys
7
32
  import tempfile
8
33
  import uuid
9
- from concurrent.futures import ThreadPoolExecutor, as_completed
10
- from datetime import datetime, timezone
11
- from urllib.parse import quote
34
+ from functools import partial
12
35
 
13
- from .backup_bundle import decode_bundle, encode_bundle
14
- from .claude_usage import _decode_jwt_claims
15
36
  from .codex_usage import fetch_codex_rate_limits
16
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
37
+ from .config import (
38
+ PERMISSION_INPUT_VALUES,
39
+ PERMISSION_VALUES,
40
+ PROVIDER_CLAUDE,
41
+ PROVIDER_CODEX,
42
+ REASONING_EFFORT_VALUES,
43
+ get_cdx_home,
44
+ normalize_permission,
45
+ )
17
46
  from .errors import CdxError
18
47
  from .fs_utils import atomic_write, remove_tree
48
+ from .session_backup import ( # noqa: F401 (re-exported for callers and tests)
49
+ _FORCE_IMPORT_PRESERVED_PROFILE_PATHS,
50
+ _auth_bundle_paths,
51
+ _build_export_session_record,
52
+ _collect_auth_files,
53
+ _force_import_preserved_profile_paths,
54
+ _resolve_session_subset,
55
+ _restore_force_import_profile_paths,
56
+ _restore_import_backup,
57
+ _safe_relpath,
58
+ _validate_import_profile_files,
59
+ export_bundle,
60
+ import_bundle,
61
+ )
62
+ from .session_helpers import ( # noqa: F401 (re-exported for src/__init__.py, callers and tests)
63
+ ALLOWED_PROVIDERS,
64
+ DEFAULT_PROVIDER,
65
+ MAX_SESSION_NAME_LENGTH,
66
+ RESERVED_SESSION_NAMES,
67
+ _encode,
68
+ _ensure_private_dir,
69
+ _get_global_codex_home,
70
+ _get_session_auth_home,
71
+ _get_session_root,
72
+ _local_now_iso,
73
+ _normalize_provider,
74
+ _process_is_running,
75
+ _runtime_is_active,
76
+ _session_runtime,
77
+ _validate_new_session_name,
78
+ list_sessions,
79
+ )
80
+ from .session_status import ( # noqa: F401 (re-exported for callers and tests)
81
+ CLAUDE_STATUS_CACHE_TTL_SECONDS,
82
+ CODEX_STATUS_CACHE_TTL_SECONDS,
83
+ MAX_STATUS_WORKERS,
84
+ STATUS_CACHE_TTL_SECONDS,
85
+ _compute_available_pct,
86
+ _fetch_codex_status,
87
+ _is_low_confidence_status_source,
88
+ _is_status_cache_fresh,
89
+ _is_status_newer,
90
+ _merge_status_payload,
91
+ _normalize_pct_value,
92
+ _normalize_status_payload,
93
+ _parse_status_timestamp,
94
+ _read_expected_account_email,
95
+ _resolve_row_session,
96
+ _resolve_session_status,
97
+ _row_priority,
98
+ _row_reasoning_effort,
99
+ _status_cache_hit,
100
+ _status_cache_ttl_seconds,
101
+ _status_has_more_detail,
102
+ _status_row_from_session,
103
+ _to_local_iso,
104
+ format_list_rows,
105
+ get_status_row,
106
+ get_status_rows,
107
+ record_status,
108
+ )
19
109
  from .session_store import create_session_store
20
- from .status_source import find_latest_status_artifact
21
110
 
22
- DEFAULT_PROVIDER = PROVIDER_CODEX
23
- ALLOWED_PROVIDERS = set(PROVIDERS)
24
- MAX_SESSION_NAME_LENGTH = 64
25
111
  MAX_SESSION_LABEL_LENGTH = 64
26
- RESERVED_SESSION_NAMES = {
27
- "add",
28
- "can-resume",
29
- "clean",
30
- "context",
31
- "configs",
32
- "cp",
33
- "disable",
34
- "disk",
35
- "doctor",
36
- "enable",
37
- "export",
38
- "fast",
39
- "help",
40
- "handoff",
41
- "history",
42
- "import",
43
- "last",
44
- "label",
45
- "login",
46
- "logout",
47
- "memory",
48
- "model",
49
- "mv",
50
- "next",
51
- "notify",
52
- "perm",
53
- "power",
54
- "ready",
55
- "repair",
56
- "reset",
57
- "resume",
58
- "ren",
59
- "rename",
60
- "rmv",
61
- "run",
62
- "select",
63
- "config",
64
- "set",
65
- "stats",
66
- "status",
67
- "unset",
68
- "update",
69
- "view",
70
- "version",
71
- "--help",
72
- "-h",
73
- "--version",
74
- "-v",
75
- }
76
- STATUS_CACHE_TTL_SECONDS = 60
77
- CLAUDE_STATUS_CACHE_TTL_SECONDS = 10 * 60
78
112
  # Each live Codex probe spawns app-server, which can refresh+rotate the OAuth
79
113
  # token. Cache longer so routine status calls don't keep triggering refreshes.
80
- CODEX_STATUS_CACHE_TTL_SECONDS = 5 * 60
81
114
  STATUS_PROBE_TIMEOUT_SECONDS = 5
82
- MAX_STATUS_WORKERS = 8
83
- LAUNCH_POWER_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
84
- LAUNCH_REASONING_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
85
- LAUNCH_PERMISSION_VALUES = {"review", "default", "auto", "full"}
115
+ # References to the shared definitions in config, not copies. These used to be
116
+ # three separate literals here, validated independently of the ones `cdx run`
117
+ # uses, which is how `cdx set` and `cdx run` came to disagree about the same
118
+ # setting.
119
+ LAUNCH_POWER_VALUES = REASONING_EFFORT_VALUES
120
+ LAUNCH_REASONING_EFFORT_VALUES = REASONING_EFFORT_VALUES
121
+ LAUNCH_PERMISSION_VALUES = PERMISSION_VALUES
86
122
  MAX_LAUNCH_MODEL_LENGTH = 128
87
123
  MIN_LAUNCH_PRIORITY = 0
88
124
  MAX_LAUNCH_PRIORITY = 100
@@ -92,25 +128,6 @@ DEFAULT_LAUNCH_SETTINGS = {
92
128
  }
93
129
 
94
130
 
95
- def _encode(name):
96
- return quote(name, safe="")
97
-
98
-
99
- def _ensure_private_dir(path):
100
- os.makedirs(path, exist_ok=True)
101
- if sys.platform == "win32":
102
- return
103
- try:
104
- os.chmod(path, 0o700)
105
- except OSError:
106
- pass
107
-
108
-
109
- def _get_global_codex_home(env=None):
110
- env = env or os.environ
111
- return env.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
112
-
113
-
114
131
  def _seed_codex_auth_from_global(auth_home, env=None):
115
132
  source_home = _get_global_codex_home(env)
116
133
  source_auth = os.path.join(source_home, "auth.json")
@@ -136,9 +153,17 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
136
153
  raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}")
137
154
  normalized["reasoning_effort"] = effort
138
155
  if "permission" in settings and settings["permission"] is not None:
139
- permission = str(settings["permission"]).strip().lower()
140
- if permission not in LAUNCH_PERMISSION_VALUES:
141
- raise CdxError(f"Unsupported permission: {settings['permission']}")
156
+ # Accepts the provider-native aliases (workspace-write, read-only,
157
+ # danger-full-access) exactly as `cdx run --permission` does, and stores
158
+ # the canonical value, so nothing downstream has to learn the aliases.
159
+ # They used to work on `run` only, so a value copied from one command to
160
+ # the other was rejected.
161
+ permission = normalize_permission(settings["permission"])
162
+ if permission is None:
163
+ allowed = "|".join(PERMISSION_INPUT_VALUES)
164
+ raise CdxError(
165
+ f"Unsupported permission: {settings['permission']} (allowed values: {allowed})"
166
+ )
142
167
  normalized["permission"] = permission
143
168
  if "fast" in settings and settings["fast"] is not None:
144
169
  value = settings["fast"]
@@ -200,102 +225,6 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
200
225
  return normalized
201
226
 
202
227
 
203
- def _local_now_iso():
204
- return datetime.now().astimezone().isoformat()
205
-
206
-
207
- def _process_is_running(pid):
208
- try:
209
- pid = int(pid)
210
- except (TypeError, ValueError):
211
- return False
212
- if pid <= 0:
213
- return False
214
- try:
215
- os.kill(pid, 0)
216
- except ProcessLookupError:
217
- return False
218
- except PermissionError:
219
- return True
220
- except OSError:
221
- return False
222
- return True
223
-
224
-
225
- def _safe_relpath(path):
226
- normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
227
- if (
228
- not normalized
229
- or normalized == "."
230
- or normalized.startswith("/")
231
- or (len(normalized) > 1 and normalized[1] == ":")
232
- or normalized == ".."
233
- or normalized.startswith("../")
234
- ):
235
- raise CdxError("Bundle contains an unsafe file path.")
236
- return normalized
237
-
238
-
239
- def _to_local_iso(value):
240
- if not value:
241
- return value
242
- try:
243
- parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
244
- except (TypeError, ValueError):
245
- return value
246
- if parsed.tzinfo is None:
247
- parsed = parsed.replace(tzinfo=timezone.utc)
248
- return parsed.astimezone().isoformat()
249
-
250
-
251
- def _normalize_pct_value(value):
252
- if value is None:
253
- return None
254
- try:
255
- return max(0, min(100, round(float(value))))
256
- except (TypeError, ValueError):
257
- return None
258
-
259
-
260
- def _normalize_status_payload(payload=None):
261
- if payload is None:
262
- payload = {}
263
- now = _local_now_iso()
264
- return {
265
- "usage_pct": _normalize_pct_value(payload.get("usage_pct")),
266
- "remaining_5h_pct": _normalize_pct_value(payload.get("remaining_5h_pct")),
267
- "remaining_week_pct": _normalize_pct_value(payload.get("remaining_week_pct")),
268
- "credits": payload.get("credits"),
269
- "reset_credits_available": payload.get("reset_credits_available"),
270
- "reset_credits": payload.get("reset_credits"),
271
- "reset_5h_at": payload.get("reset_5h_at"),
272
- "reset_week_at": payload.get("reset_week_at"),
273
- "reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
274
- "updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
275
- "raw_status_text": payload.get("raw_status_text"),
276
- "source_ref": payload.get("source_ref"),
277
- "structured": bool(payload.get("structured")),
278
- }
279
-
280
-
281
- def _parse_status_timestamp(value):
282
- if not value:
283
- return None
284
- try:
285
- return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
286
- except (TypeError, ValueError):
287
- return None
288
-
289
-
290
- def _status_cache_ttl_seconds(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
291
- if ttl_seconds == STATUS_CACHE_TTL_SECONDS:
292
- if session.get("provider") == PROVIDER_CLAUDE:
293
- return CLAUDE_STATUS_CACHE_TTL_SECONDS
294
- if session.get("provider") == PROVIDER_CODEX:
295
- return CODEX_STATUS_CACHE_TTL_SECONDS
296
- return ttl_seconds
297
-
298
-
299
228
  def _parse_status_timeout_seconds(value):
300
229
  if value in (None, ""):
301
230
  return None
@@ -308,131 +237,6 @@ def _parse_status_timeout_seconds(value):
308
237
  return parsed
309
238
 
310
239
 
311
- def _is_status_cache_fresh(session, ttl_seconds=STATUS_CACHE_TTL_SECONDS):
312
- status = session.get("lastStatus") or {}
313
- if _is_low_confidence_status_source(status):
314
- return False
315
- updated_at = _parse_status_timestamp(status.get("updated_at") or session.get("lastStatusAt"))
316
- if not updated_at:
317
- return False
318
- now = datetime.now(timezone.utc).astimezone()
319
- ttl_seconds = _status_cache_ttl_seconds(session, ttl_seconds)
320
- return (now - updated_at.astimezone(now.tzinfo)).total_seconds() < ttl_seconds
321
-
322
-
323
- def _is_status_newer(candidate, current):
324
- if not candidate:
325
- return False
326
- if not current:
327
- return True
328
- candidate_at = _parse_status_timestamp(candidate.get("updated_at"))
329
- current_at = _parse_status_timestamp(current.get("updated_at"))
330
- if candidate_at and current_at:
331
- return candidate_at > current_at
332
- if candidate_at:
333
- return True
334
- return False
335
-
336
-
337
- def _status_has_more_detail(candidate, current):
338
- if not candidate:
339
- return False
340
- if not current:
341
- return True
342
-
343
- fields = [
344
- "usage_pct",
345
- "remaining_5h_pct",
346
- "remaining_week_pct",
347
- "credits",
348
- "reset_credits_available",
349
- "reset_credits",
350
- "reset_5h_at",
351
- "reset_week_at",
352
- "reset_at",
353
- "raw_status_text",
354
- "source_ref",
355
- ]
356
- return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
357
-
358
-
359
- def _merge_status_payload(current, candidate):
360
- if not current:
361
- return candidate
362
- if not candidate:
363
- return current
364
-
365
- merged = dict(current)
366
- for field in [
367
- "usage_pct",
368
- "remaining_5h_pct",
369
- "remaining_week_pct",
370
- "credits",
371
- "reset_credits_available",
372
- "reset_credits",
373
- "reset_5h_at",
374
- "reset_week_at",
375
- "reset_at",
376
- "raw_status_text",
377
- "source_ref",
378
- ]:
379
- if merged.get(field) is None and candidate.get(field) is not None:
380
- merged[field] = candidate[field]
381
-
382
- # The structured marker qualifies the source_ref; keep them in sync when
383
- # the merge adopts the candidate's ref, else a structured rollout ref
384
- # would read as low-confidence scraped text.
385
- if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
386
- merged["structured"] = bool(candidate.get("structured"))
387
-
388
- merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
389
- return merged
390
-
391
-
392
- def _compute_available_pct(status):
393
- if not status:
394
- return None
395
- values = [
396
- _normalize_pct_value(status.get("remaining_5h_pct")),
397
- _normalize_pct_value(status.get("remaining_week_pct")),
398
- ]
399
- values = [value for value in values if value is not None]
400
- if not values:
401
- return None
402
- return min(values)
403
-
404
-
405
- def _is_low_confidence_status_source(status):
406
- if not status:
407
- return False
408
- if status.get("structured"):
409
- # Exact rate_limits API data: trustworthy even when it comes from a
410
- # sessions/rollout path, which otherwise flags scraped-text noise.
411
- return False
412
- source_ref = str(status.get("source_ref") or "").replace(os.sep, "/")
413
- return "/sessions/" in source_ref and "/rollout" in source_ref
414
-
415
-
416
- def _read_expected_account_email(auth_home):
417
- auth_path = os.path.join(auth_home, "auth.json")
418
- try:
419
- with open(auth_path, encoding="utf-8") as handle:
420
- auth = json.load(handle)
421
- except (FileNotFoundError, OSError, json.JSONDecodeError):
422
- return None
423
-
424
- tokens = auth.get("tokens") or {}
425
- for token_name in ("id_token", "access_token"):
426
- claims = _decode_jwt_claims(tokens.get(token_name))
427
- email = claims.get("email")
428
- if not email and token_name == "access_token":
429
- profile = claims.get("https://api.openai.com/profile") or {}
430
- email = profile.get("email")
431
- if email:
432
- return str(email).strip().lower()
433
- return None
434
-
435
-
436
240
  def _ensure_claude_attribution_disabled(auth_home):
437
241
  settings_dir = os.path.join(auth_home, ".claude")
438
242
  settings_path = os.path.join(settings_dir, "settings.json")
@@ -454,1128 +258,496 @@ def _ensure_claude_attribution_disabled(auth_home):
454
258
  return True
455
259
 
456
260
 
457
- def create_session_service(options=None):
458
- if options is None:
459
- options = {}
460
- env = options.get("env", os.environ)
461
- base_dir = options.get("base_dir") or get_cdx_home(env)
462
- store = options.get("store") or create_session_store(base_dir)
463
- custom_codex_status_fetcher = options.get("fetchCodexRateLimits")
464
- codex_status_fetcher = custom_codex_status_fetcher or fetch_codex_rate_limits
465
- default_status_timeout_seconds = (
466
- _parse_status_timeout_seconds(options.get("statusTimeoutSeconds"))
467
- or _parse_status_timeout_seconds(env.get("CDX_STATUS_TIMEOUT_SECONDS"))
468
- or STATUS_PROBE_TIMEOUT_SECONDS
469
- )
470
-
471
- def _fetch_codex_status(session, timeout_seconds=None):
472
- if custom_codex_status_fetcher:
473
- return custom_codex_status_fetcher(session)
474
- timeout = timeout_seconds or default_status_timeout_seconds
475
- return codex_status_fetcher(session, timeout=timeout)
476
-
477
- def _get_session_root(name):
478
- return os.path.join(base_dir, "profiles", _encode(name))
479
-
480
- def _get_session_auth_home(name, provider):
481
- root = _get_session_root(name)
482
- if provider == PROVIDER_CLAUDE:
483
- return os.path.join(root, "claude-home")
484
- if provider == PROVIDER_ANTIGRAVITY:
485
- return os.path.join(root, "antigravity-home")
486
- return root
487
-
488
- def _normalize_provider(provider):
489
- value = provider or DEFAULT_PROVIDER
490
- if value not in ALLOWED_PROVIDERS:
491
- raise CdxError(f"Unsupported provider: {value}")
492
- return value
493
-
494
- def _runtime_is_active(runtime):
495
- return (
496
- isinstance(runtime, dict)
497
- and runtime.get("status") == "running"
498
- and _process_is_running(runtime.get("pid"))
499
- )
500
-
501
- def _session_runtime(name):
502
- found = {}
503
-
504
- def updater(state):
505
- if not state:
506
- return None
507
- runtime = state.get("runtime")
508
- if _runtime_is_active(runtime):
509
- found["runtime"] = runtime
510
- return None
511
- if isinstance(runtime, dict) and runtime.get("status") == "running":
512
- return {
513
- **state,
514
- "status": "ready",
515
- "runtime": {
516
- **runtime,
517
- "status": "stale",
518
- "endedAt": _local_now_iso(),
519
- },
520
- }
521
- return None
261
+ def _normalize_session_label(label):
262
+ text = str(label or "").strip()
263
+ if not text:
264
+ raise CdxError("Session label is required")
265
+ if len(text) > MAX_SESSION_LABEL_LENGTH:
266
+ raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
267
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
268
+ raise CdxError("Session label cannot contain control characters")
269
+ return text
270
+
271
+
272
+ def set_launch_settings(store, name, settings):
273
+ session = store["get_session"](name)
274
+ if not session:
275
+ raise CdxError(f"Unknown session: {name}")
276
+ updates = _normalize_launch_settings(settings)
277
+ if not updates:
278
+ raise CdxError("At least one launch setting is required.")
279
+ now = _local_now_iso()
522
280
 
523
- store["update_session_state"](name, updater)
524
- return found.get("runtime")
525
-
526
- def _validate_new_session_name(name):
527
- if not name:
528
- raise CdxError("Session name is required")
529
- if str(name) != str(name).strip():
530
- raise CdxError("Session name cannot start or end with whitespace")
531
- if str(name) in (".", ".."):
532
- raise CdxError("Session name cannot be . or ..")
533
- if len(str(name)) > MAX_SESSION_NAME_LENGTH:
534
- raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
535
- if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
536
- raise CdxError("Session name cannot contain control characters")
537
- if name in RESERVED_SESSION_NAMES:
538
- raise CdxError(f"Session name is reserved: {name}")
539
-
540
- def _normalize_session_label(label):
541
- text = str(label or "").strip()
542
- if not text:
543
- raise CdxError("Session label is required")
544
- if len(text) > MAX_SESSION_LABEL_LENGTH:
545
- raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
546
- if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
547
- raise CdxError("Session label cannot contain control characters")
548
- return text
549
-
550
- def _build_export_session_record(session):
281
+ def updater(s):
282
+ # Build the new launch dict from the fresh record inside the store
283
+ # lock; a pre-lock snapshot loses a concurrent setting change.
284
+ current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
285
+ launch = {**current, **updates}
286
+ if "power" in updates:
287
+ launch.pop("reasoning_effort", None)
288
+ launch.pop("reasoningEffort", None)
289
+ if "reasoning_effort" in updates:
290
+ launch.pop("power", None)
291
+ explicit_power = "power" in updates or "reasoning_effort" in updates
292
+ if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
293
+ launch["fast"] = False
294
+ launch.pop("fastMode", None)
295
+ if updates.get("fast") is False:
296
+ launch.pop("fastMode", None)
297
+ if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
298
+ launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
551
299
  return {
552
- "name": session["name"],
553
- "provider": session["provider"],
554
- "label": session.get("label"),
555
- "enabled": session.get("enabled", True) is not False,
556
- "createdAt": session.get("createdAt"),
557
- "updatedAt": session.get("updatedAt"),
558
- "lastLaunchedAt": session.get("lastLaunchedAt"),
559
- "lastStatusAt": session.get("lastStatusAt"),
560
- "lastStatus": session.get("lastStatus"),
561
- "launch": session.get("launch"),
562
- "auth": session.get("auth"),
563
- }
564
-
565
- def _auth_bundle_paths(provider):
566
- if provider == PROVIDER_CLAUDE:
567
- return [
568
- "claude-home/configs/default.json",
569
- "claude-home/credentials/default.json",
570
- "claude-home/.claude/.credentials.json",
571
- "claude-home/.claude.json",
572
- "claude-home/auth.json",
573
- ]
574
- return [
575
- "auth.json",
576
- ]
577
-
578
- def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
579
- files = []
580
- total_bytes = 0
581
- if not os.path.isdir(session_root):
582
- return {"files": files, "file_count": 0, "bytes": 0}
583
- for rel_path in _auth_bundle_paths(provider):
584
- full_path = os.path.join(session_root, rel_path)
585
- if not os.path.isfile(full_path):
586
- continue
587
- with open(full_path, "rb") as handle:
588
- raw_content = handle.read()
589
- total_bytes += len(raw_content)
590
- content = base64.b64encode(raw_content).decode("ascii")
591
- files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
592
- if progress_callback:
593
- progress_callback({
594
- "event": "profile_progress",
595
- "session_name": session_name,
596
- "file_count": len(files),
597
- "bytes": total_bytes,
598
- })
599
- return {"files": files, "file_count": len(files), "bytes": total_bytes}
600
-
601
- def _resolve_session_subset(session_names):
602
- if not session_names:
603
- return list_sessions()
604
- by_name = {session["name"]: session for session in list_sessions()}
605
- selected = []
606
- for name in session_names:
607
- session = by_name.get(name)
608
- if not session:
609
- raise CdxError(f"Unknown session: {name}")
610
- selected.append(session)
611
- return selected
612
-
613
- def create_session(name, provider=DEFAULT_PROVIDER):
614
- _validate_new_session_name(name)
615
- normalized_provider = _normalize_provider(provider)
616
- session_root = _get_session_root(name)
617
- auth_home = _get_session_auth_home(name, normalized_provider)
618
- _ensure_private_dir(base_dir)
619
- _ensure_private_dir(os.path.join(base_dir, "profiles"))
620
- _ensure_private_dir(session_root)
621
- _ensure_private_dir(auth_home)
622
- if normalized_provider == PROVIDER_CODEX:
623
- _seed_codex_auth_from_global(auth_home, env=env)
624
- if normalized_provider == PROVIDER_CLAUDE:
625
- _ensure_claude_attribution_disabled(auth_home)
626
- now = _local_now_iso()
627
- session = {
628
- "name": name,
629
- "provider": normalized_provider,
630
- "enabled": True,
631
- "sessionRoot": session_root,
632
- "authHome": auth_home,
633
- "createdAt": now,
634
- "updatedAt": now,
635
- "lastLaunchedAt": None,
636
- "lastStatusAt": None,
637
- "lastStatus": None,
638
- "launch": dict(DEFAULT_LAUNCH_SETTINGS),
639
- "auth": {
640
- "status": "unknown",
641
- "lastCheckedAt": None,
642
- "lastAuthenticatedAt": None,
643
- "lastLoggedOutAt": None,
644
- },
645
- }
646
- result = store["add_session"](session)
647
- if not result["ok"]:
648
- raise CdxError(f"Session already exists: {name}")
649
- return result["session"]
650
-
651
- def remove_session(name):
652
- session = store["get_session"](name)
653
- if not session:
654
- raise CdxError(f"Unknown session: {name}")
655
- session_root = session.get("sessionRoot") or _get_session_root(name)
656
- quarantine_root = None
657
- if os.path.exists(session_root):
658
- profiles_dir = os.path.dirname(session_root)
659
- os.makedirs(profiles_dir, exist_ok=True)
660
- quarantine_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.remove.", dir=profiles_dir)
661
- os.rmdir(quarantine_root)
662
- os.rename(session_root, quarantine_root)
663
- try:
664
- removed = store["remove_session"](name)
665
- except Exception:
666
- if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
667
- os.rename(quarantine_root, session_root)
668
- raise
669
- if not removed:
670
- if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
671
- os.rename(quarantine_root, session_root)
672
- raise CdxError(f"Unknown session: {name}")
673
- if quarantine_root:
674
- try:
675
- remove_tree(quarantine_root)
676
- except OSError as error:
677
- raise CdxError(
678
- f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
679
- ) from error
680
- return removed
681
-
682
- def copy_session(source_name, dest_name):
683
- if source_name == dest_name:
684
- raise CdxError("Source and destination session names must be different")
685
- _validate_new_session_name(dest_name)
686
- source = store["get_session"](source_name)
687
- if not source:
688
- raise CdxError(f"Unknown session: {source_name}")
689
- existing = store["get_session"](dest_name)
690
- overwritten = False
691
- source_root = source.get("sessionRoot") or _get_session_root(source_name)
692
- dest_root = _get_session_root(dest_name)
693
- if not existing and os.path.exists(dest_root):
694
- raise CdxError(f"Session profile already exists: {dest_name}")
695
- dest_auth_home = _get_session_auth_home(dest_name, source["provider"])
696
- profiles_dir = os.path.dirname(dest_root)
697
- os.makedirs(profiles_dir, exist_ok=True)
698
- temp_parent = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.copy.", dir=profiles_dir)
699
- temp_root = os.path.join(temp_parent, "profile")
700
- backup_root = None
701
- moved_temp = False
702
- now = _local_now_iso()
703
- replacement = {
704
- "name": dest_name,
705
- "provider": source["provider"],
706
- "enabled": True,
707
- "sessionRoot": dest_root,
708
- "authHome": dest_auth_home,
709
- "createdAt": now,
300
+ **s,
301
+ "launch": launch,
710
302
  "updatedAt": now,
711
- "lastLaunchedAt": None,
712
- "lastStatusAt": None,
713
- "lastStatus": None,
714
- **({"launch": source.get("launch")} if source.get("launch") else {}),
715
- **({"label": source.get("label")} if source.get("label") else {}),
716
- "auth": {
717
- "status": "unknown",
718
- "lastCheckedAt": None,
719
- "lastAuthenticatedAt": None,
720
- "lastLoggedOutAt": None,
721
- },
722
303
  }
723
- try:
724
- shutil.copytree(source_root, temp_root)
725
- if existing:
726
- backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
727
- os.rmdir(backup_root)
728
- if os.path.exists(dest_root):
729
- os.rename(dest_root, backup_root)
730
- os.rename(temp_root, dest_root)
731
- moved_temp = True
732
- result = store["replace_session"](dest_name, replacement)
733
- overwritten = bool(existing)
734
- except Exception:
735
- if moved_temp and os.path.exists(dest_root):
736
- remove_tree(dest_root, ignore_errors=True)
737
- if backup_root and os.path.exists(backup_root) and not os.path.exists(dest_root):
738
- os.rename(backup_root, dest_root)
739
- raise
740
- finally:
741
- if backup_root and os.path.exists(backup_root):
742
- remove_tree(backup_root, ignore_errors=True)
743
- remove_tree(temp_parent, ignore_errors=True)
744
- if not result["ok"]:
745
- raise CdxError(f"Failed to create session: {dest_name}")
746
- return {"session": result["session"], "overwritten": overwritten}
747
-
748
- def rename_session(source_name, dest_name):
749
- if source_name == dest_name:
750
- raise CdxError("Source and destination session names must be different")
751
- _validate_new_session_name(dest_name)
752
- source = store["get_session"](source_name)
753
- if not source:
754
- raise CdxError(f"Unknown session: {source_name}")
755
- if store["get_session"](dest_name):
756
- raise CdxError(f"Session already exists: {dest_name}")
757
304
 
758
- source_root = source.get("sessionRoot") or _get_session_root(source_name)
759
- dest_root = _get_session_root(dest_name)
760
- if os.path.exists(dest_root):
761
- raise CdxError(f"Session profile already exists: {dest_name}")
305
+ return store["update_session"](name, updater)
306
+
307
+ def unset_launch_settings(store, name, keys):
308
+ session = store["get_session"](name)
309
+ if not session:
310
+ raise CdxError(f"Unknown session: {name}")
311
+ if not keys:
312
+ raise CdxError("At least one launch setting is required.")
313
+ allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
314
+ unknown = [key for key in keys if key not in allowed]
315
+ if unknown:
316
+ raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
317
+ now = _local_now_iso()
762
318
 
763
- if os.path.exists(source_root):
764
- os.rename(source_root, dest_root)
765
- moved_profile = True
319
+ def updater(s):
320
+ current = dict(s.get("launch") or {})
321
+ for key in keys:
322
+ current.pop(key, None)
323
+ updated = {**s, "updatedAt": now}
324
+ if current:
325
+ updated["launch"] = current
766
326
  else:
767
- moved_profile = False
327
+ updated.pop("launch", None)
328
+ return updated
768
329
 
769
- now = _local_now_iso()
770
- try:
771
- result = store["rename_session"](source_name, dest_name, lambda s: {
772
- **s,
773
- "name": dest_name,
774
- "sessionRoot": dest_root,
775
- "authHome": _get_session_auth_home(dest_name, s["provider"]),
776
- "updatedAt": now,
777
- })
778
- except Exception:
779
- if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
780
- os.rename(dest_root, source_root)
781
- raise
782
-
783
- if not result["ok"]:
784
- if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
785
- os.rename(dest_root, source_root)
786
- if result["reason"] == "exists":
787
- raise CdxError(f"Session already exists: {dest_name}")
788
- raise CdxError(f"Unknown session: {source_name}")
789
- return result["session"]
790
-
791
- def launch_session(name):
792
- session = store["get_session"](name)
793
- if not session:
794
- raise CdxError(f"Unknown session: {name}")
795
- if session.get("enabled", True) is False:
796
- raise CdxError(f"Session is disabled: {name}")
797
- now = _local_now_iso()
798
-
799
- def state_updater(state):
800
- if not state:
801
- raise CdxError(f"Session state missing for {name}. Reconnect required.")
802
- return {**state, "rehydratedAt": now}
803
-
804
- store["update_session_state"](name, state_updater)
805
- return store["update_session"](name, lambda s: {
806
- **s, "updatedAt": now, "lastLaunchedAt": now
807
- })
330
+ return store["update_session"](name, updater)
808
331
 
809
- def start_session_runtime(name, payload=None):
810
- session = store["get_session"](name)
811
- if not session:
812
- raise CdxError(f"Unknown session: {name}")
813
- payload = dict(payload or {})
814
- now = _local_now_iso()
815
- runtime = {
816
- "status": "running",
817
- "runId": payload.get("runId") or uuid.uuid4().hex,
818
- "startedAt": payload.get("startedAt") or now,
819
- "pid": payload.get("pid") or os.getpid(),
820
- "command": payload.get("command"),
821
- "label": payload.get("label"),
822
- "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
332
+ def update_auth_state(store, name, updater):
333
+ now = _local_now_iso()
334
+ updated = store["update_session"](name, lambda s: {
335
+ **s,
336
+ "updatedAt": now,
337
+ "auth": updater(s.get("auth") or {}),
338
+ })
339
+ if not updated:
340
+ raise CdxError(f"Unknown session: {name}")
341
+ return updated
342
+
343
+
344
+ def get_session(store, name):
345
+ return store["get_session"](name)
346
+
347
+
348
+ def ensure_session_state(store, name):
349
+ session = store["get_session"](name)
350
+ if not session:
351
+ raise CdxError(f"Unknown session: {name}")
352
+ result = {}
353
+
354
+ def updater(state):
355
+ if state:
356
+ result["state"] = state
357
+ return None
358
+ repaired = {
359
+ "provider": session["provider"],
360
+ "status": "ready",
361
+ "rehydratedAt": None,
823
362
  }
363
+ result["state"] = repaired
364
+ return repaired
824
365
 
825
- def updater(state):
826
- if not state:
827
- raise CdxError(f"Session state missing for {name}. Reconnect required.")
828
- return {
829
- **state,
830
- "status": "running",
831
- "runtime": runtime,
832
- }
833
-
834
- store["update_session_state"](name, updater)
835
- return runtime
836
-
837
- def finish_session_runtime(name, run_id=None, payload=None):
838
- session = store["get_session"](name)
839
- if not session:
840
- raise CdxError(f"Unknown session: {name}")
841
- payload = dict(payload or {})
842
- outcome = {}
843
-
844
- def updater(state):
845
- if not state:
846
- return None
847
- runtime = state.get("runtime") or {}
848
- if run_id and runtime.get("runId") != run_id:
849
- outcome["runtime"] = runtime
850
- return None
851
- updated_runtime = {
852
- **runtime,
853
- "status": payload.get("status") or "stopped",
854
- "endedAt": payload.get("endedAt") or _local_now_iso(),
855
- }
856
- if "returncode" in payload:
857
- updated_runtime["returncode"] = payload["returncode"]
858
- outcome["runtime"] = updated_runtime
859
- return {
860
- **state,
861
- "status": "ready",
862
- "runtime": updated_runtime,
863
- }
864
-
865
- store["update_session_state"](name, updater)
866
- return outcome.get("runtime")
867
-
868
- def ensure_session_state(name):
869
- session = store["get_session"](name)
870
- if not session:
871
- raise CdxError(f"Unknown session: {name}")
872
- result = {}
873
-
874
- def updater(state):
875
- if state:
876
- result["state"] = state
877
- return None
878
- repaired = {
879
- "provider": session["provider"],
880
- "status": "ready",
881
- "rehydratedAt": None,
882
- }
883
- result["state"] = repaired
884
- return repaired
885
-
886
- store["update_session_state"](name, updater)
887
- return result["state"]
888
-
889
- def list_sessions():
890
- return store["list_sessions"]()
891
-
892
- def get_session(name):
893
- return store["get_session"](name)
894
-
895
- def set_session_enabled(name, enabled):
896
- session = store["get_session"](name)
897
- if not session:
898
- raise CdxError(f"Unknown session: {name}")
899
- now = _local_now_iso()
900
- return store["update_session"](name, lambda s: {
901
- **s,
902
- "enabled": bool(enabled),
903
- "updatedAt": now,
904
- })
366
+ store["update_session_state"](name, updater)
367
+ return result["state"]
905
368
 
906
- def set_session_label(name, label):
907
- session = store["get_session"](name)
908
- if not session:
909
- raise CdxError(f"Unknown session: {name}")
910
- normalized = _normalize_session_label(label)
911
- now = _local_now_iso()
912
- return store["update_session"](name, lambda s: {
913
- **s,
914
- "label": normalized,
915
- "updatedAt": now,
916
- })
917
369
 
918
- def clear_session_label(name):
919
- session = store["get_session"](name)
920
- if not session:
921
- raise CdxError(f"Unknown session: {name}")
922
- now = _local_now_iso()
923
-
924
- def updater(s):
925
- updated = {**s, "updatedAt": now}
926
- updated.pop("label", None)
927
- return updated
928
-
929
- return store["update_session"](name, updater)
930
-
931
- def set_launch_settings(name, settings):
932
- session = store["get_session"](name)
933
- if not session:
934
- raise CdxError(f"Unknown session: {name}")
935
- updates = _normalize_launch_settings(settings)
936
- if not updates:
937
- raise CdxError("At least one launch setting is required.")
938
- now = _local_now_iso()
939
-
940
- def updater(s):
941
- # Build the new launch dict from the fresh record inside the store
942
- # lock; a pre-lock snapshot loses a concurrent setting change.
943
- current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
944
- launch = {**current, **updates}
945
- if "power" in updates:
946
- launch.pop("reasoning_effort", None)
947
- launch.pop("reasoningEffort", None)
948
- if "reasoning_effort" in updates:
949
- launch.pop("power", None)
950
- explicit_power = "power" in updates or "reasoning_effort" in updates
951
- if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
952
- launch["fast"] = False
953
- launch.pop("fastMode", None)
954
- if updates.get("fast") is False:
955
- launch.pop("fastMode", None)
956
- if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
957
- launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
958
- return {
959
- **s,
960
- "launch": launch,
961
- "updatedAt": now,
962
- }
963
-
964
- return store["update_session"](name, updater)
965
-
966
- def unset_launch_settings(name, keys):
967
- session = store["get_session"](name)
968
- if not session:
969
- raise CdxError(f"Unknown session: {name}")
970
- if not keys:
971
- raise CdxError("At least one launch setting is required.")
972
- allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
973
- unknown = [key for key in keys if key not in allowed]
974
- if unknown:
975
- raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
976
- now = _local_now_iso()
977
-
978
- def updater(s):
979
- current = dict(s.get("launch") or {})
980
- for key in keys:
981
- current.pop(key, None)
982
- updated = {**s, "updatedAt": now}
983
- if current:
984
- updated["launch"] = current
985
- else:
986
- updated.pop("launch", None)
987
- return updated
370
+ def set_session_label(store, name, label):
371
+ session = store["get_session"](name)
372
+ if not session:
373
+ raise CdxError(f"Unknown session: {name}")
374
+ normalized = _normalize_session_label(label)
375
+ now = _local_now_iso()
376
+ return store["update_session"](name, lambda s: {
377
+ **s,
378
+ "label": normalized,
379
+ "updatedAt": now,
380
+ })
988
381
 
989
- return store["update_session"](name, updater)
990
382
 
991
- def record_status(name, payload):
992
- normalized = _normalize_status_payload(payload)
993
- updated = store["update_session"](name, lambda s: {
994
- **s,
995
- "lastStatus": normalized,
996
- "lastStatusAt": normalized["updated_at"],
997
- })
998
- if not updated:
999
- raise CdxError(f"Unknown session: {name}")
383
+ def clear_session_label(store, name):
384
+ session = store["get_session"](name)
385
+ if not session:
386
+ raise CdxError(f"Unknown session: {name}")
387
+ now = _local_now_iso()
388
+
389
+ def updater(s):
390
+ updated = {**s, "updatedAt": now}
391
+ updated.pop("label", None)
1000
392
  return updated
1001
393
 
1002
- def record_launch_history(name, payload):
1003
- session = store["get_session"](name)
1004
- if not session:
1005
- raise CdxError(f"Unknown session: {name}")
1006
- entry = {
1007
- "schema_version": 1,
1008
- "session_name": session["name"],
1009
- "provider": session["provider"],
1010
- "launch": session.get("launch") or {},
1011
- **payload,
1012
- }
1013
- store["append_launch_history"](entry)
1014
- return entry
1015
-
1016
- def get_launch_history(name=None, limit=20):
1017
- if name and not store["get_session"](name):
1018
- raise CdxError(f"Unknown session: {name}")
1019
- return store["list_launch_history"](session_name=name, limit=limit)
1020
-
1021
- def _resolve_session_status(
1022
- session,
1023
- force_refresh=False,
1024
- cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
1025
- cache_only=False,
1026
- status_timeout_seconds=None,
1027
- ):
1028
- current_status = session.get("lastStatus")
1029
- if session.get("enabled", True) is False:
1030
- return current_status
1031
- if cache_only:
1032
- return current_status
1033
- if current_status and not force_refresh and _is_status_cache_fresh(session, ttl_seconds=cache_ttl_seconds):
1034
- return current_status
1035
- source_root = session.get("authHome") or _get_session_auth_home(
1036
- session["name"], session["provider"]
1037
- )
1038
- if session["provider"] == PROVIDER_CODEX and codex_status_fetcher:
1039
- live_status = _fetch_codex_status(
1040
- {**session, "authHome": source_root},
1041
- timeout_seconds=status_timeout_seconds,
1042
- )
1043
- if live_status:
1044
- record_status(session["name"], live_status)
1045
- return live_status
1046
-
1047
- expected_account_email = (
1048
- _read_expected_account_email(source_root)
1049
- if session["provider"] == PROVIDER_CODEX
1050
- else None
1051
- )
1052
- artifact = find_latest_status_artifact(
1053
- source_root,
1054
- session["provider"],
1055
- expected_account_email=expected_account_email,
1056
- )
1057
- if (
1058
- session["provider"] == PROVIDER_CODEX
1059
- and not artifact
1060
- and os.path.abspath(base_dir) == os.path.abspath(get_cdx_home(env))
1061
- ):
1062
- global_root = _get_global_codex_home(env)
1063
- if global_root and os.path.abspath(global_root) != os.path.abspath(source_root):
1064
- artifact = find_latest_status_artifact(
1065
- global_root,
1066
- session["provider"],
1067
- expected_account_email=expected_account_email,
1068
- # The shared codex home can hold other accounts' rollouts;
1069
- # structured payloads there cannot be attributed to ours.
1070
- trust_unattributed_structured=False,
1071
- )
1072
- if not artifact:
1073
- if _is_low_confidence_status_source(current_status):
1074
- return None
1075
- return current_status
1076
- resolved = _normalize_status_payload({
1077
- "usage_pct": artifact.get("usage_pct"),
1078
- "remaining_5h_pct": artifact.get("remaining_5h_pct"),
1079
- "remaining_week_pct": artifact.get("remaining_week_pct"),
1080
- "credits": artifact.get("credits"),
1081
- "reset_5h_at": artifact.get("reset_5h_at"),
1082
- "reset_week_at": artifact.get("reset_week_at"),
1083
- "reset_at": artifact.get("reset_at"),
1084
- "updated_at": artifact.get("updated_at"),
1085
- "raw_status_text": artifact.get("raw_status_text"),
1086
- "source_ref": artifact.get("source_ref"),
1087
- "structured": artifact.get("structured"),
1088
- })
1089
- if _is_low_confidence_status_source(current_status) and not _is_low_confidence_status_source(resolved):
1090
- record_status(session["name"], resolved)
1091
- return resolved
1092
- if _is_status_newer(resolved, current_status):
1093
- record_status(session["name"], resolved)
1094
- return resolved
1095
- if _status_has_more_detail(resolved, current_status):
1096
- merged = _merge_status_payload(current_status, resolved)
1097
- record_status(session["name"], merged)
1098
- return merged
1099
- return current_status or resolved
1100
-
1101
- def update_auth_state(name, updater):
1102
- now = _local_now_iso()
1103
- updated = store["update_session"](name, lambda s: {
394
+ return store["update_session"](name, updater)
395
+
396
+
397
+ def set_session_enabled(store, name, enabled):
398
+ session = store["get_session"](name)
399
+ if not session:
400
+ raise CdxError(f"Unknown session: {name}")
401
+ now = _local_now_iso()
402
+ return store["update_session"](name, lambda s: {
403
+ **s,
404
+ "enabled": bool(enabled),
405
+ "updatedAt": now,
406
+ })
407
+
408
+
409
+ def remove_session(base_dir, store, name):
410
+ session = store["get_session"](name)
411
+ if not session:
412
+ raise CdxError(f"Unknown session: {name}")
413
+ session_root = session.get("sessionRoot") or _get_session_root(base_dir, name)
414
+ quarantine_root = None
415
+ if os.path.exists(session_root):
416
+ profiles_dir = os.path.dirname(session_root)
417
+ os.makedirs(profiles_dir, exist_ok=True)
418
+ quarantine_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.remove.", dir=profiles_dir)
419
+ os.rmdir(quarantine_root)
420
+ os.rename(session_root, quarantine_root)
421
+ try:
422
+ removed = store["remove_session"](name)
423
+ except Exception:
424
+ if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
425
+ os.rename(quarantine_root, session_root)
426
+ raise
427
+ if not removed:
428
+ if quarantine_root and os.path.exists(quarantine_root) and not os.path.exists(session_root):
429
+ os.rename(quarantine_root, session_root)
430
+ raise CdxError(f"Unknown session: {name}")
431
+ if quarantine_root:
432
+ try:
433
+ remove_tree(quarantine_root)
434
+ except OSError as error:
435
+ raise CdxError(
436
+ f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
437
+ ) from error
438
+ return removed
439
+
440
+
441
+ def copy_session(base_dir, store, source_name, dest_name):
442
+ if source_name == dest_name:
443
+ raise CdxError("Source and destination session names must be different")
444
+ _validate_new_session_name(dest_name)
445
+ source = store["get_session"](source_name)
446
+ if not source:
447
+ raise CdxError(f"Unknown session: {source_name}")
448
+ existing = store["get_session"](dest_name)
449
+ overwritten = False
450
+ source_root = source.get("sessionRoot") or _get_session_root(base_dir, source_name)
451
+ dest_root = _get_session_root(base_dir, dest_name)
452
+ if not existing and os.path.exists(dest_root):
453
+ raise CdxError(f"Session profile already exists: {dest_name}")
454
+ dest_auth_home = _get_session_auth_home(base_dir, dest_name, source["provider"])
455
+ profiles_dir = os.path.dirname(dest_root)
456
+ os.makedirs(profiles_dir, exist_ok=True)
457
+ temp_parent = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.copy.", dir=profiles_dir)
458
+ temp_root = os.path.join(temp_parent, "profile")
459
+ backup_root = None
460
+ moved_temp = False
461
+ now = _local_now_iso()
462
+ replacement = {
463
+ "name": dest_name,
464
+ "provider": source["provider"],
465
+ "enabled": True,
466
+ "sessionRoot": dest_root,
467
+ "authHome": dest_auth_home,
468
+ "createdAt": now,
469
+ "updatedAt": now,
470
+ "lastLaunchedAt": None,
471
+ "lastStatusAt": None,
472
+ "lastStatus": None,
473
+ **({"launch": source.get("launch")} if source.get("launch") else {}),
474
+ **({"label": source.get("label")} if source.get("label") else {}),
475
+ "auth": {
476
+ "status": "unknown",
477
+ "lastCheckedAt": None,
478
+ "lastAuthenticatedAt": None,
479
+ "lastLoggedOutAt": None,
480
+ },
481
+ }
482
+ try:
483
+ shutil.copytree(source_root, temp_root)
484
+ if existing:
485
+ backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
486
+ os.rmdir(backup_root)
487
+ if os.path.exists(dest_root):
488
+ os.rename(dest_root, backup_root)
489
+ os.rename(temp_root, dest_root)
490
+ moved_temp = True
491
+ result = store["replace_session"](dest_name, replacement)
492
+ overwritten = bool(existing)
493
+ except Exception:
494
+ if moved_temp and os.path.exists(dest_root):
495
+ remove_tree(dest_root, ignore_errors=True)
496
+ if backup_root and os.path.exists(backup_root) and not os.path.exists(dest_root):
497
+ os.rename(backup_root, dest_root)
498
+ raise
499
+ finally:
500
+ if backup_root and os.path.exists(backup_root):
501
+ remove_tree(backup_root, ignore_errors=True)
502
+ remove_tree(temp_parent, ignore_errors=True)
503
+ if not result["ok"]:
504
+ raise CdxError(f"Failed to create session: {dest_name}")
505
+ return {"session": result["session"], "overwritten": overwritten}
506
+
507
+
508
+ def rename_session(base_dir, store, source_name, dest_name):
509
+ if source_name == dest_name:
510
+ raise CdxError("Source and destination session names must be different")
511
+ _validate_new_session_name(dest_name)
512
+ source = store["get_session"](source_name)
513
+ if not source:
514
+ raise CdxError(f"Unknown session: {source_name}")
515
+ if store["get_session"](dest_name):
516
+ raise CdxError(f"Session already exists: {dest_name}")
517
+
518
+ source_root = source.get("sessionRoot") or _get_session_root(base_dir, source_name)
519
+ dest_root = _get_session_root(base_dir, dest_name)
520
+ if os.path.exists(dest_root):
521
+ raise CdxError(f"Session profile already exists: {dest_name}")
522
+
523
+ if os.path.exists(source_root):
524
+ os.rename(source_root, dest_root)
525
+ moved_profile = True
526
+ else:
527
+ moved_profile = False
528
+
529
+ now = _local_now_iso()
530
+ try:
531
+ result = store["rename_session"](source_name, dest_name, lambda s: {
1104
532
  **s,
533
+ "name": dest_name,
534
+ "sessionRoot": dest_root,
535
+ "authHome": _get_session_auth_home(base_dir, dest_name, s["provider"]),
1105
536
  "updatedAt": now,
1106
- "auth": updater(s.get("auth") or {}),
1107
537
  })
1108
- if not updated:
1109
- raise CdxError(f"Unknown session: {name}")
1110
- return updated
538
+ except Exception:
539
+ if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
540
+ os.rename(dest_root, source_root)
541
+ raise
542
+
543
+ if not result["ok"]:
544
+ if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
545
+ os.rename(dest_root, source_root)
546
+ if result["reason"] == "exists":
547
+ raise CdxError(f"Session already exists: {dest_name}")
548
+ raise CdxError(f"Unknown session: {source_name}")
549
+ return result["session"]
550
+
551
+
552
+ def create_session(base_dir, env, store, name, provider=DEFAULT_PROVIDER):
553
+ _validate_new_session_name(name)
554
+ normalized_provider = _normalize_provider(provider)
555
+ session_root = _get_session_root(base_dir, name)
556
+ auth_home = _get_session_auth_home(base_dir, name, normalized_provider)
557
+ _ensure_private_dir(base_dir)
558
+ _ensure_private_dir(os.path.join(base_dir, "profiles"))
559
+ _ensure_private_dir(session_root)
560
+ _ensure_private_dir(auth_home)
561
+ if normalized_provider == PROVIDER_CODEX:
562
+ _seed_codex_auth_from_global(auth_home, env=env)
563
+ if normalized_provider == PROVIDER_CLAUDE:
564
+ _ensure_claude_attribution_disabled(auth_home)
565
+ now = _local_now_iso()
566
+ session = {
567
+ "name": name,
568
+ "provider": normalized_provider,
569
+ "enabled": True,
570
+ "sessionRoot": session_root,
571
+ "authHome": auth_home,
572
+ "createdAt": now,
573
+ "updatedAt": now,
574
+ "lastLaunchedAt": None,
575
+ "lastStatusAt": None,
576
+ "lastStatus": None,
577
+ "launch": dict(DEFAULT_LAUNCH_SETTINGS),
578
+ "auth": {
579
+ "status": "unknown",
580
+ "lastCheckedAt": None,
581
+ "lastAuthenticatedAt": None,
582
+ "lastLoggedOutAt": None,
583
+ },
584
+ }
585
+ result = store["add_session"](session)
586
+ if not result["ok"]:
587
+ raise CdxError(f"Session already exists: {name}")
588
+ return result["session"]
589
+
590
+
591
+ def record_launch_history(store, name, payload):
592
+ session = store["get_session"](name)
593
+ if not session:
594
+ raise CdxError(f"Unknown session: {name}")
595
+ entry = {
596
+ "schema_version": 1,
597
+ "session_name": session["name"],
598
+ "provider": session["provider"],
599
+ "launch": session.get("launch") or {},
600
+ **payload,
601
+ }
602
+ store["append_launch_history"](entry)
603
+ return entry
1111
604
 
1112
- def _status_cache_hit(s, force_refresh=False, cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS, cache_only=False):
1113
- return (
1114
- cache_only
1115
- or s.get("enabled", True) is False
1116
- or (
1117
- s.get("lastStatus")
1118
- and not force_refresh
1119
- and _is_status_cache_fresh(s, ttl_seconds=cache_ttl_seconds)
1120
- )
1121
- )
1122
-
1123
- def _resolve_row_session(
1124
- s,
1125
- force_refresh=False,
1126
- cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
1127
- cache_only=False,
1128
- status_timeout_seconds=None,
1129
- ):
1130
- status = _resolve_session_status(
1131
- s,
1132
- force_refresh=force_refresh,
1133
- cache_ttl_seconds=cache_ttl_seconds,
1134
- cache_only=cache_only,
1135
- status_timeout_seconds=status_timeout_seconds,
1136
- )
1137
- return {
1138
- **s,
1139
- "lastStatus": status,
1140
- "lastStatusAt": (status and status.get("updated_at")) or s.get("lastStatusAt"),
1141
- }
1142
605
 
1143
- def _status_row_from_session(s):
1144
- status = s.get("lastStatus")
1145
- enabled = s.get("enabled", True) is not False
1146
- row_status = status if enabled else None
606
+ def get_launch_history(store, name=None, limit=20):
607
+ if name and not store["get_session"](name):
608
+ raise CdxError(f"Unknown session: {name}")
609
+ return store["list_launch_history"](session_name=name, limit=limit)
610
+
611
+
612
+ def start_session_runtime(store, name, payload=None):
613
+ session = store["get_session"](name)
614
+ if not session:
615
+ raise CdxError(f"Unknown session: {name}")
616
+ payload = dict(payload or {})
617
+ now = _local_now_iso()
618
+ runtime = {
619
+ "status": "running",
620
+ "runId": payload.get("runId") or uuid.uuid4().hex,
621
+ "startedAt": payload.get("startedAt") or now,
622
+ "pid": payload.get("pid") or os.getpid(),
623
+ "command": payload.get("command"),
624
+ "label": payload.get("label"),
625
+ "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
626
+ }
627
+
628
+ def updater(state):
629
+ if not state:
630
+ raise CdxError(f"Session state missing for {name}. Reconnect required.")
1147
631
  return {
1148
- "session_name": s["name"],
1149
- "label": s.get("label"),
1150
- "provider": s["provider"],
1151
- "enabled": enabled,
1152
- "active": bool(_session_runtime(s["name"])) if enabled else False,
1153
- "status": "enabled" if enabled else "disabled",
1154
- "auth_status": (s.get("auth") or {}).get("status") or "unknown",
1155
- "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
1156
- "remaining_5h_pct": _normalize_pct_value(row_status.get("remaining_5h_pct")) if row_status else None,
1157
- "remaining_week_pct": _normalize_pct_value(row_status.get("remaining_week_pct")) if row_status else None,
1158
- "credits": row_status.get("credits") if row_status else None,
1159
- "reset_credits_available": row_status.get("reset_credits_available") if row_status else None,
1160
- "reset_credits": row_status.get("reset_credits") if row_status else None,
1161
- "available_pct": _compute_available_pct(row_status),
1162
- "reset_5h_at": row_status.get("reset_5h_at") if row_status else None,
1163
- "reset_week_at": row_status.get("reset_week_at") if row_status else None,
1164
- "reset_at": row_status.get("reset_at") if row_status else None,
1165
- "updated_at": _to_local_iso(s.get("lastStatusAt")),
1166
- "last_launched_at": _to_local_iso(s.get("lastLaunchedAt")),
632
+ **state,
633
+ "status": "running",
634
+ "runtime": runtime,
1167
635
  }
1168
636
 
1169
- def get_status_row(
1170
- name,
1171
- progress_callback=None,
1172
- force_refresh=False,
1173
- cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
1174
- cache_only=False,
1175
- status_timeout_seconds=None,
1176
- ):
1177
- session = store["get_session"](name)
1178
- if not session:
1179
- raise CdxError(f"Unknown session: {name}")
1180
- cache_hit = _status_cache_hit(
1181
- session,
1182
- force_refresh=force_refresh,
1183
- cache_ttl_seconds=cache_ttl_seconds,
1184
- cache_only=cache_only,
1185
- )
1186
- if progress_callback:
1187
- progress_callback({
1188
- "event": "status_started",
1189
- "session_count": 1,
1190
- "check_count": 0 if cache_hit else 1,
1191
- })
1192
- if not cache_hit:
1193
- progress_callback({
1194
- "event": "session_started",
1195
- "session_name": session["name"],
1196
- "provider": session["provider"],
1197
- })
1198
- resolved = _resolve_row_session(
1199
- session,
1200
- force_refresh=force_refresh,
1201
- cache_ttl_seconds=cache_ttl_seconds,
1202
- cache_only=cache_only,
1203
- status_timeout_seconds=status_timeout_seconds,
1204
- )
1205
- if progress_callback:
1206
- progress_callback({
1207
- "event": "session_finished",
1208
- "session_name": session["name"],
1209
- "has_status": bool(resolved.get("lastStatus")),
1210
- "cache_hit": cache_hit,
1211
- })
1212
- progress_callback({
1213
- "event": "status_finished",
1214
- "row_count": 1,
1215
- })
1216
- return _status_row_from_session(resolved)
1217
-
1218
- def get_status_rows(
1219
- progress_callback=None,
1220
- force_refresh=False,
1221
- cache_ttl_seconds=STATUS_CACHE_TTL_SECONDS,
1222
- cache_only=False,
1223
- status_timeout_seconds=None,
1224
- ):
1225
- sessions = list_sessions()
1226
-
1227
- cache_hits = {
1228
- s["name"]: _status_cache_hit(
1229
- s,
1230
- force_refresh=force_refresh,
1231
- cache_ttl_seconds=cache_ttl_seconds,
1232
- cache_only=cache_only,
1233
- )
1234
- for s in sessions
1235
- }
1236
- if progress_callback:
1237
- progress_callback({
1238
- "event": "status_started",
1239
- "session_count": len(sessions),
1240
- "check_count": sum(1 for cache_hit in cache_hits.values() if not cache_hit),
1241
- })
1242
-
1243
- resolved_by_name = {}
1244
- if sessions:
1245
- max_workers = min(MAX_STATUS_WORKERS, len(sessions))
1246
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
1247
- futures = {}
1248
- for s in sessions:
1249
- cache_hit = cache_hits[s["name"]]
1250
- if progress_callback and not cache_hit:
1251
- progress_callback({
1252
- "event": "session_started",
1253
- "session_name": s["name"],
1254
- "provider": s["provider"],
1255
- })
1256
- futures[executor.submit(
1257
- _resolve_row_session,
1258
- s,
1259
- force_refresh=force_refresh,
1260
- cache_ttl_seconds=cache_ttl_seconds,
1261
- cache_only=cache_only,
1262
- status_timeout_seconds=status_timeout_seconds,
1263
- )] = s
1264
- for future in as_completed(futures):
1265
- s = futures[future]
1266
- try:
1267
- resolved = future.result()
1268
- except CdxError as error:
1269
- if not str(error).startswith("Unknown session:"):
1270
- raise
1271
- continue
1272
- resolved_by_name[s["name"]] = resolved
1273
- if progress_callback:
1274
- progress_callback({
1275
- "event": "session_finished",
1276
- "session_name": s["name"],
1277
- "has_status": bool(resolved.get("lastStatus")),
1278
- "cache_hit": cache_hits[s["name"]],
1279
- })
1280
- resolved = [resolved_by_name[s["name"]] for s in sessions if s["name"] in resolved_by_name]
1281
-
1282
- def sort_key(s):
1283
- at = s.get("lastStatusAt") or ""
1284
- disabled_rank = 1 if s.get("enabled", True) is False else 0
1285
- return (disabled_rank, "" if at else "\xff", at, s["name"])
1286
-
1287
- resolved.sort(key=sort_key)
1288
- enabled = [s for s in resolved if s.get("enabled", True) is not False]
1289
- disabled = [s for s in resolved if s.get("enabled", True) is False]
1290
- enabled.reverse()
1291
- disabled.sort(key=lambda s: s["name"])
1292
- resolved = enabled + disabled
1293
-
1294
- rows = []
1295
- for s in resolved:
1296
- rows.append(_status_row_from_session(s))
1297
- if progress_callback:
1298
- progress_callback({
1299
- "event": "status_finished",
1300
- "row_count": len(rows),
1301
- })
1302
- return rows
1303
-
1304
- def format_list_rows():
1305
- sessions = list_sessions()
1306
- providers = {s["provider"] for s in sessions}
1307
- has_multiple = len(providers) > 1
1308
- sessions = sorted(
1309
- sessions,
1310
- key=lambda s: (
1311
- 1 if s.get("enabled", True) is False else 0,
1312
- s.get("name", ""),
1313
- ),
1314
- )
1315
- return [{
1316
- "name": s["name"],
1317
- "label": s.get("label"),
1318
- "provider": s["provider"] if has_multiple else None,
1319
- "enabled": s.get("enabled", True) is not False,
1320
- "active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
1321
- "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
1322
- "status": s.get("lastStatus"),
1323
- "launch": s.get("launch") or {},
1324
- "updated_at": _to_local_iso(s.get("updatedAt")),
1325
- } for s in sessions]
1326
-
1327
- def get_session_auth_home(name, provider):
1328
- return _get_session_auth_home(name, provider)
1329
-
1330
- def get_session_root(name):
1331
- return _get_session_root(name)
1332
-
1333
- def export_bundle(file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
1334
- if not file_path:
1335
- raise CdxError("Export path is required.")
1336
- if os.path.exists(file_path) and not force:
1337
- raise CdxError(f"Export path already exists: {file_path}")
1338
-
1339
- sessions = _resolve_session_subset(session_names)
1340
- payload = {
1341
- "schema_version": 1,
1342
- "created_at": _local_now_iso(),
1343
- "include_auth": bool(include_auth),
1344
- "sessions": [],
1345
- "states": {},
1346
- "profiles": {},
637
+ store["update_session_state"](name, updater)
638
+ return runtime
639
+
640
+
641
+ def finish_session_runtime(store, name, run_id=None, payload=None):
642
+ session = store["get_session"](name)
643
+ if not session:
644
+ raise CdxError(f"Unknown session: {name}")
645
+ payload = dict(payload or {})
646
+ outcome = {}
647
+
648
+ def updater(state):
649
+ if not state:
650
+ return None
651
+ runtime = state.get("runtime") or {}
652
+ if run_id and runtime.get("runId") != run_id:
653
+ outcome["runtime"] = runtime
654
+ return None
655
+ updated_runtime = {
656
+ **runtime,
657
+ "status": payload.get("status") or "stopped",
658
+ "endedAt": payload.get("endedAt") or _local_now_iso(),
1347
659
  }
1348
- profile_file_count = 0
1349
- profile_bytes = 0
1350
- if progress_callback:
1351
- progress_callback({
1352
- "event": "export_started",
1353
- "include_auth": bool(include_auth),
1354
- "session_count": len(sessions),
1355
- "session_names": [session["name"] for session in sessions],
1356
- })
1357
- for session in sessions:
1358
- if progress_callback:
1359
- progress_callback({"event": "session_started", "session_name": session["name"]})
1360
- payload["sessions"].append(_build_export_session_record(session))
1361
- state = store["read_session_state"](session["name"])
1362
- if state is not None:
1363
- payload["states"][session["name"]] = state
1364
- if include_auth:
1365
- session_root = session.get("sessionRoot") or _get_session_root(session["name"])
1366
- profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
1367
- payload["profiles"][session["name"]] = profile["files"]
1368
- profile_file_count += profile["file_count"]
1369
- profile_bytes += profile["bytes"]
1370
- if progress_callback:
1371
- progress_callback({"event": "session_finished", "session_name": session["name"]})
1372
-
1373
- if progress_callback:
1374
- progress_callback({"event": "encoding_started"})
1375
- bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
1376
- if progress_callback:
1377
- progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
1378
- # Atomic + 0o600 from the start: the export path may live in a
1379
- # world-traversable directory, and a crash must not clobber a
1380
- # previous good bundle with a truncated one.
1381
- atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
660
+ if "returncode" in payload:
661
+ updated_runtime["returncode"] = payload["returncode"]
662
+ outcome["runtime"] = updated_runtime
1382
663
  return {
1383
- "path": file_path,
1384
- "include_auth": include_auth,
1385
- "session_names": [session["name"] for session in sessions],
1386
- "session_count": len(sessions),
1387
- "profile_file_count": profile_file_count if include_auth else None,
1388
- "profile_bytes": profile_bytes if include_auth else None,
1389
- "bundle_size_bytes": len(bundle_bytes),
664
+ **state,
665
+ "status": "ready",
666
+ "runtime": updated_runtime,
1390
667
  }
1391
668
 
1392
- def _validate_import_profile_files(payload, imported_sessions):
1393
- profiles = payload.get("profiles") or {}
1394
- if not isinstance(profiles, dict):
1395
- raise CdxError("Bundle contains invalid profile data.")
1396
- decoded_profiles = {}
1397
- for session_payload in imported_sessions:
1398
- name = session_payload["name"]
1399
- files = []
1400
- profile_items = profiles.get(name, [])
1401
- if not isinstance(profile_items, list):
1402
- raise CdxError(f"Bundle contains invalid profile data for session {name}.")
1403
- for item in profile_items:
1404
- if not isinstance(item, dict):
1405
- raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
1406
- rel_path = _safe_relpath(item.get("path"))
1407
- data_b64 = item.get("data_b64")
1408
- if not isinstance(data_b64, str):
1409
- raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
1410
- try:
1411
- content = base64.b64decode(data_b64.encode("ascii"), validate=True)
1412
- except (binascii.Error, UnicodeEncodeError) as error:
1413
- raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
1414
- files.append({"path": rel_path, "content": content})
1415
- decoded_profiles[name] = files
1416
- return decoded_profiles
1417
-
1418
- def _restore_import_backup(name, backup_root, session_root, old_record, old_state):
1419
- if os.path.exists(session_root):
1420
- remove_tree(session_root, ignore_errors=True)
1421
- if backup_root and os.path.exists(backup_root):
1422
- os.rename(backup_root, session_root)
1423
- if old_record:
1424
- store["replace_session"](name, old_record)
1425
- if old_state is not None:
1426
- store["write_session_state"](name, old_state)
1427
- else:
1428
- store["remove_session"](name)
1429
-
1430
- def import_bundle(
1431
- file_path,
1432
- passphrase=None,
1433
- session_names=None,
1434
- force=False,
1435
- merge=False,
1436
- allow_authless_force=False,
1437
- ):
1438
- if not file_path or not os.path.isfile(file_path):
1439
- raise CdxError(f"Bundle file not found: {file_path}")
1440
- with open(file_path, "rb") as handle:
1441
- decoded = decode_bundle(handle.read(), passphrase=passphrase)
1442
- payload = decoded["payload"]
1443
- imported_sessions = payload.get("sessions") or []
1444
- if payload.get("schema_version") != 1:
1445
- raise CdxError("Unsupported bundle payload schema version.")
1446
-
1447
- selected_names = set(session_names or [])
1448
- if selected_names:
1449
- imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
1450
- missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
1451
- if missing_names:
1452
- raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
1453
- names = [item["name"] for item in imported_sessions]
1454
-
1455
- existing = {session["name"] for session in list_sessions()}
1456
- conflicts = [name for name in names if name in existing]
1457
- if conflicts and not force and not merge:
1458
- raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
1459
- if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
1460
- raise CdxError(
1461
- "Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
1462
- "Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
1463
- )
669
+ store["update_session_state"](name, updater)
670
+ return outcome.get("runtime")
1464
671
 
1465
- for session_payload in imported_sessions:
1466
- _validate_new_session_name(session_payload["name"])
1467
- _normalize_provider(session_payload["provider"])
1468
- decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
1469
-
1470
- for session_payload in imported_sessions:
1471
- name = session_payload["name"]
1472
- provider = _normalize_provider(session_payload["provider"])
1473
- is_existing = name in existing
1474
-
1475
- session_root = _get_session_root(name)
1476
- auth_home = _get_session_auth_home(name, provider)
1477
- _ensure_private_dir(base_dir)
1478
- _ensure_private_dir(os.path.join(base_dir, "profiles"))
1479
- backup_root = None
1480
- old_record = None
1481
- old_state = None
1482
- if is_existing and force:
1483
- old_record = store["get_session"](name)
1484
- old_state = store["read_session_state"](name)
1485
- if os.path.exists(session_root):
1486
- backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
1487
- os.rmdir(backup_root)
1488
- os.rename(session_root, backup_root)
1489
- is_existing = False
1490
- _ensure_private_dir(session_root)
1491
- _ensure_private_dir(auth_home)
1492
-
1493
- existing_state_before = None
1494
- try:
1495
- if is_existing and merge:
1496
- existing_record = store["get_session"](name) or {}
1497
- # replace_session resets the state file to defaults, so the
1498
- # local state must be captured before it runs.
1499
- existing_state_before = store["read_session_state"](name) or {}
1500
- bundle_record = {
1501
- **session_payload,
1502
- "provider": provider,
1503
- "enabled": session_payload.get("enabled", True) is not False,
1504
- "sessionRoot": session_root,
1505
- "authHome": auth_home,
1506
- }
1507
- # Existing values take precedence; bundle fills in missing keys only.
1508
- merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
1509
- merged_record["sessionRoot"] = session_root
1510
- merged_record["authHome"] = auth_home
1511
- store["replace_session"](name, merged_record)
1512
- else:
1513
- session_record = {
1514
- **session_payload,
1515
- "provider": provider,
1516
- "enabled": session_payload.get("enabled", True) is not False,
1517
- "sessionRoot": session_root,
1518
- "authHome": auth_home,
1519
- }
1520
- store["replace_session"](name, session_record)
1521
-
1522
- state = (payload.get("states") or {}).get(name)
1523
- if is_existing and merge:
1524
- merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
1525
- if merged_state:
1526
- store["write_session_state"](name, merged_state)
1527
- elif state is not None:
1528
- store["write_session_state"](name, state)
1529
-
1530
- for item in decoded_profiles.get(name, []):
1531
- dest_path = os.path.join(session_root, item["path"])
1532
- # In merge mode, skip files that already exist locally.
1533
- if is_existing and merge and os.path.exists(dest_path):
1534
- continue
1535
- _ensure_private_dir(os.path.dirname(dest_path))
1536
- # Decrypted credentials: 0o600 from creation, no umask window.
1537
- atomic_write(dest_path, item["content"], mode=0o600)
1538
- except Exception:
1539
- _restore_import_backup(name, backup_root, session_root, old_record, old_state)
1540
- raise
1541
- finally:
1542
- if backup_root and os.path.exists(backup_root):
1543
- remove_tree(backup_root, ignore_errors=True)
1544
672
 
1545
- return {
1546
- "path": file_path,
1547
- "session_names": names,
1548
- "include_auth": bool(decoded["meta"].get("include_auth")),
1549
- }
673
+ def launch_session(store, name):
674
+ session = store["get_session"](name)
675
+ if not session:
676
+ raise CdxError(f"Unknown session: {name}")
677
+ if session.get("enabled", True) is False:
678
+ raise CdxError(f"Session is disabled: {name}")
679
+ now = _local_now_iso()
680
+
681
+ def state_updater(state):
682
+ if not state:
683
+ raise CdxError(f"Session state missing for {name}. Reconnect required.")
684
+ return {**state, "rehydratedAt": now}
685
+
686
+ store["update_session_state"](name, state_updater)
687
+ return store["update_session"](name, lambda s: {
688
+ **s, "updatedAt": now, "lastLaunchedAt": now
689
+ })
690
+
691
+
692
+ def get_session_root(base_dir, name):
693
+ return _get_session_root(base_dir, name)
694
+
695
+
696
+ def get_session_auth_home(base_dir, name, provider):
697
+ return _get_session_auth_home(base_dir, name, provider)
698
+
699
+
700
+ def create_session_service(options=None):
701
+ if options is None:
702
+ options = {}
703
+ env = options.get("env", os.environ)
704
+ base_dir = options.get("base_dir") or get_cdx_home(env)
705
+ store = options.get("store") or create_session_store(base_dir)
706
+ custom_codex_status_fetcher = options.get("fetchCodexRateLimits")
707
+ codex_status_fetcher = custom_codex_status_fetcher or fetch_codex_rate_limits
708
+ default_status_timeout_seconds = (
709
+ _parse_status_timeout_seconds(options.get("statusTimeoutSeconds"))
710
+ or _parse_status_timeout_seconds(env.get("CDX_STATUS_TIMEOUT_SECONDS"))
711
+ or STATUS_PROBE_TIMEOUT_SECONDS
712
+ )
713
+
714
+ # None when there is no fetcher, so callers test the callable they were
715
+ # given rather than a separate flag they would also have to be passed.
716
+ fetch_codex_status = partial(
717
+ _fetch_codex_status,
718
+ codex_status_fetcher,
719
+ custom_codex_status_fetcher,
720
+ default_status_timeout_seconds,
721
+ ) if codex_status_fetcher else None
1550
722
 
1551
723
  return {
1552
- "create_session": create_session,
1553
- "remove_session": remove_session,
1554
- "copy_session": copy_session,
1555
- "rename_session": rename_session,
1556
- "launch_session": launch_session,
1557
- "active_session_runtime": _session_runtime,
1558
- "start_session_runtime": start_session_runtime,
1559
- "finish_session_runtime": finish_session_runtime,
1560
- "ensure_session_state": ensure_session_state,
1561
- "list_sessions": list_sessions,
1562
- "get_session": get_session,
1563
- "set_session_enabled": set_session_enabled,
1564
- "set_session_label": set_session_label,
1565
- "clear_session_label": clear_session_label,
1566
- "set_launch_settings": set_launch_settings,
1567
- "unset_launch_settings": unset_launch_settings,
1568
- "record_status": record_status,
1569
- "record_launch_history": record_launch_history,
1570
- "get_launch_history": get_launch_history,
1571
- "update_auth_state": update_auth_state,
1572
- "get_status_row": get_status_row,
1573
- "get_status_rows": get_status_rows,
1574
- "format_list_rows": format_list_rows,
1575
- "get_session_auth_home": get_session_auth_home,
1576
- "get_session_root": get_session_root,
1577
- "export_bundle": export_bundle,
1578
- "import_bundle": import_bundle,
724
+ "create_session": partial(create_session, base_dir, env, store),
725
+ "remove_session": partial(remove_session, base_dir, store),
726
+ "copy_session": partial(copy_session, base_dir, store),
727
+ "rename_session": partial(rename_session, base_dir, store),
728
+ "launch_session": partial(launch_session, store),
729
+ "active_session_runtime": partial(_session_runtime, store),
730
+ "start_session_runtime": partial(start_session_runtime, store),
731
+ "finish_session_runtime": partial(finish_session_runtime, store),
732
+ "ensure_session_state": partial(ensure_session_state, store),
733
+ "list_sessions": partial(list_sessions, store),
734
+ "get_session": partial(get_session, store),
735
+ "set_session_enabled": partial(set_session_enabled, store),
736
+ "set_session_label": partial(set_session_label, store),
737
+ "clear_session_label": partial(clear_session_label, store),
738
+ "set_launch_settings": partial(set_launch_settings, store),
739
+ "unset_launch_settings": partial(unset_launch_settings, store),
740
+ "record_status": partial(record_status, store),
741
+ "record_launch_history": partial(record_launch_history, store),
742
+ "get_launch_history": partial(get_launch_history, store),
743
+ "update_auth_state": partial(update_auth_state, store),
744
+ "get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
745
+ "get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
746
+ "format_list_rows": partial(format_list_rows, store),
747
+ "get_session_auth_home": partial(get_session_auth_home, base_dir),
748
+ "get_session_root": partial(get_session_root, base_dir),
749
+ "export_bundle": partial(export_bundle, base_dir, store),
750
+ "import_bundle": partial(import_bundle, base_dir, store),
1579
751
  "base_dir": base_dir,
1580
752
  "normalize_provider": _normalize_provider,
1581
753
  }