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