cdx-manager 0.12.4 → 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.
@@ -0,0 +1,343 @@
1
+ """Backup domain: exporting and importing session bundles.
2
+
3
+ Split out of session_service.py along the second of the two seams the req_027
4
+ coupling measurement supports. Moved verbatim; re-exported by session_service.
5
+ """
6
+
7
+ import base64
8
+ import binascii
9
+ import os
10
+ import shutil
11
+ import tempfile
12
+
13
+ from .backup_bundle import decode_bundle, encode_bundle
14
+ from .config import PROVIDER_CLAUDE
15
+ from .errors import CdxError
16
+ from .fs_utils import atomic_write, remove_tree
17
+ from .session_helpers import (
18
+ _encode,
19
+ _ensure_private_dir,
20
+ _get_session_auth_home,
21
+ _get_session_root,
22
+ _local_now_iso,
23
+ _normalize_provider,
24
+ _validate_new_session_name,
25
+ list_sessions,
26
+ )
27
+
28
+ _FORCE_IMPORT_PRESERVED_PROFILE_PATHS = ("plugins",)
29
+
30
+ def _safe_relpath(path):
31
+ normalized = os.path.normpath(str(path or "").replace("\\", "/")).replace("\\", "/")
32
+ if (
33
+ not normalized
34
+ or normalized == "."
35
+ or normalized.startswith("/")
36
+ or (len(normalized) > 1 and normalized[1] == ":")
37
+ or normalized == ".."
38
+ or normalized.startswith("../")
39
+ ):
40
+ raise CdxError("Bundle contains an unsafe file path.")
41
+ return normalized
42
+
43
+ def _build_export_session_record(session):
44
+ return {
45
+ "name": session["name"],
46
+ "provider": session["provider"],
47
+ "label": session.get("label"),
48
+ "enabled": session.get("enabled", True) is not False,
49
+ "createdAt": session.get("createdAt"),
50
+ "updatedAt": session.get("updatedAt"),
51
+ "lastLaunchedAt": session.get("lastLaunchedAt"),
52
+ "lastStatusAt": session.get("lastStatusAt"),
53
+ "lastStatus": session.get("lastStatus"),
54
+ "launch": session.get("launch"),
55
+ "auth": session.get("auth"),
56
+ }
57
+
58
+ def _auth_bundle_paths(provider):
59
+ if provider == PROVIDER_CLAUDE:
60
+ return [
61
+ "claude-home/configs/default.json",
62
+ "claude-home/credentials/default.json",
63
+ "claude-home/.claude/.credentials.json",
64
+ "claude-home/.claude.json",
65
+ "claude-home/auth.json",
66
+ ]
67
+ return [
68
+ "auth.json",
69
+ ]
70
+
71
+ def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
72
+ files = []
73
+ total_bytes = 0
74
+ if not os.path.isdir(session_root):
75
+ return {"files": files, "file_count": 0, "bytes": 0}
76
+ for rel_path in _auth_bundle_paths(provider):
77
+ full_path = os.path.join(session_root, rel_path)
78
+ if not os.path.isfile(full_path):
79
+ continue
80
+ with open(full_path, "rb") as handle:
81
+ raw_content = handle.read()
82
+ total_bytes += len(raw_content)
83
+ content = base64.b64encode(raw_content).decode("ascii")
84
+ files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
85
+ if progress_callback:
86
+ progress_callback({
87
+ "event": "profile_progress",
88
+ "session_name": session_name,
89
+ "file_count": len(files),
90
+ "bytes": total_bytes,
91
+ })
92
+ return {"files": files, "file_count": len(files), "bytes": total_bytes}
93
+
94
+ def _validate_import_profile_files(payload, imported_sessions):
95
+ profiles = payload.get("profiles") or {}
96
+ if not isinstance(profiles, dict):
97
+ raise CdxError("Bundle contains invalid profile data.")
98
+ decoded_profiles = {}
99
+ for session_payload in imported_sessions:
100
+ name = session_payload["name"]
101
+ files = []
102
+ profile_items = profiles.get(name, [])
103
+ if not isinstance(profile_items, list):
104
+ raise CdxError(f"Bundle contains invalid profile data for session {name}.")
105
+ for item in profile_items:
106
+ if not isinstance(item, dict):
107
+ raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
108
+ rel_path = _safe_relpath(item.get("path"))
109
+ data_b64 = item.get("data_b64")
110
+ if not isinstance(data_b64, str):
111
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
112
+ try:
113
+ content = base64.b64decode(data_b64.encode("ascii"), validate=True)
114
+ except (binascii.Error, UnicodeEncodeError) as error:
115
+ raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
116
+ files.append({"path": rel_path, "content": content})
117
+ decoded_profiles[name] = files
118
+ return decoded_profiles
119
+
120
+ def _force_import_preserved_profile_paths(session_root):
121
+ return [rel_path for rel_path in _FORCE_IMPORT_PRESERVED_PROFILE_PATHS if os.path.exists(os.path.join(session_root, rel_path))]
122
+
123
+ def _restore_force_import_profile_paths(name, backup_root, session_root, rel_paths):
124
+ if not backup_root:
125
+ return
126
+ for rel_path in rel_paths:
127
+ source_path = os.path.join(backup_root, rel_path)
128
+ dest_path = os.path.join(session_root, rel_path)
129
+ if not os.path.exists(source_path) or os.path.exists(dest_path):
130
+ continue
131
+ try:
132
+ if os.path.isdir(source_path) and not os.path.islink(source_path):
133
+ shutil.copytree(source_path, dest_path)
134
+ else:
135
+ _ensure_private_dir(os.path.dirname(dest_path))
136
+ shutil.copy2(source_path, dest_path)
137
+ except Exception as error:
138
+ raise CdxError(f"Could not restore local plugin state for session {name}: {rel_path}") from error
139
+
140
+ def _resolve_session_subset(store, session_names):
141
+ if not session_names:
142
+ return list_sessions(store)
143
+ by_name = {session["name"]: session for session in list_sessions(store)}
144
+ selected = []
145
+ for name in session_names:
146
+ session = by_name.get(name)
147
+ if not session:
148
+ raise CdxError(f"Unknown session: {name}")
149
+ selected.append(session)
150
+ return selected
151
+
152
+ def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
153
+ if os.path.exists(session_root):
154
+ remove_tree(session_root, ignore_errors=True)
155
+ if backup_root and os.path.exists(backup_root):
156
+ os.rename(backup_root, session_root)
157
+ if old_record:
158
+ store["replace_session"](name, old_record)
159
+ if old_state is not None:
160
+ store["write_session_state"](name, old_state)
161
+ else:
162
+ store["remove_session"](name)
163
+
164
+ def export_bundle(base_dir, store, file_path, include_auth=False, session_names=None, passphrase=None, force=False, progress_callback=None):
165
+ if not file_path:
166
+ raise CdxError("Export path is required.")
167
+ if os.path.exists(file_path) and not force:
168
+ raise CdxError(f"Export path already exists: {file_path}")
169
+
170
+ sessions = _resolve_session_subset(store, session_names)
171
+ payload = {
172
+ "schema_version": 1,
173
+ "created_at": _local_now_iso(),
174
+ "include_auth": bool(include_auth),
175
+ "sessions": [],
176
+ "states": {},
177
+ "profiles": {},
178
+ }
179
+ profile_file_count = 0
180
+ profile_bytes = 0
181
+ if progress_callback:
182
+ progress_callback({
183
+ "event": "export_started",
184
+ "include_auth": bool(include_auth),
185
+ "session_count": len(sessions),
186
+ "session_names": [session["name"] for session in sessions],
187
+ })
188
+ for session in sessions:
189
+ if progress_callback:
190
+ progress_callback({"event": "session_started", "session_name": session["name"]})
191
+ payload["sessions"].append(_build_export_session_record(session))
192
+ state = store["read_session_state"](session["name"])
193
+ if state is not None:
194
+ payload["states"][session["name"]] = state
195
+ if include_auth:
196
+ session_root = session.get("sessionRoot") or _get_session_root(base_dir, session["name"])
197
+ profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
198
+ payload["profiles"][session["name"]] = profile["files"]
199
+ profile_file_count += profile["file_count"]
200
+ profile_bytes += profile["bytes"]
201
+ if progress_callback:
202
+ progress_callback({"event": "session_finished", "session_name": session["name"]})
203
+
204
+ if progress_callback:
205
+ progress_callback({"event": "encoding_started"})
206
+ bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
207
+ if progress_callback:
208
+ progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
209
+ # Atomic + 0o600 from the start: the export path may live in a
210
+ # world-traversable directory, and a crash must not clobber a
211
+ # previous good bundle with a truncated one.
212
+ atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
213
+ return {
214
+ "path": file_path,
215
+ "include_auth": include_auth,
216
+ "session_names": [session["name"] for session in sessions],
217
+ "session_count": len(sessions),
218
+ "profile_file_count": profile_file_count if include_auth else None,
219
+ "profile_bytes": profile_bytes if include_auth else None,
220
+ "bundle_size_bytes": len(bundle_bytes),
221
+ }
222
+
223
+ def import_bundle(base_dir, store, file_path,
224
+ passphrase=None,
225
+ session_names=None,
226
+ force=False,
227
+ merge=False,
228
+ allow_authless_force=False,):
229
+ if not file_path or not os.path.isfile(file_path):
230
+ raise CdxError(f"Bundle file not found: {file_path}")
231
+ with open(file_path, "rb") as handle:
232
+ decoded = decode_bundle(handle.read(), passphrase=passphrase)
233
+ payload = decoded["payload"]
234
+ imported_sessions = payload.get("sessions") or []
235
+ if payload.get("schema_version") != 1:
236
+ raise CdxError("Unsupported bundle payload schema version.")
237
+
238
+ selected_names = set(session_names or [])
239
+ if selected_names:
240
+ imported_sessions = [item for item in imported_sessions if item["name"] in selected_names]
241
+ missing_names = sorted(selected_names - {item["name"] for item in imported_sessions})
242
+ if missing_names:
243
+ raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
244
+ names = [item["name"] for item in imported_sessions]
245
+
246
+ existing = {session["name"] for session in list_sessions(store)}
247
+ conflicts = [name for name in names if name in existing]
248
+ if conflicts and not force and not merge:
249
+ raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
250
+ if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
251
+ raise CdxError(
252
+ "Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
253
+ "Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
254
+ )
255
+
256
+ for session_payload in imported_sessions:
257
+ _validate_new_session_name(session_payload["name"])
258
+ _normalize_provider(session_payload["provider"])
259
+ decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
260
+
261
+ for session_payload in imported_sessions:
262
+ name = session_payload["name"]
263
+ provider = _normalize_provider(session_payload["provider"])
264
+ is_existing = name in existing
265
+
266
+ session_root = _get_session_root(base_dir, name)
267
+ auth_home = _get_session_auth_home(base_dir, name, provider)
268
+ _ensure_private_dir(base_dir)
269
+ _ensure_private_dir(os.path.join(base_dir, "profiles"))
270
+ backup_root = None
271
+ old_record = None
272
+ old_state = None
273
+ preserved_profile_paths = []
274
+ if is_existing and force:
275
+ old_record = store["get_session"](name)
276
+ old_state = store["read_session_state"](name)
277
+ preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
278
+ if os.path.exists(session_root):
279
+ backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
280
+ os.rmdir(backup_root)
281
+ os.rename(session_root, backup_root)
282
+ is_existing = False
283
+ _ensure_private_dir(session_root)
284
+ _ensure_private_dir(auth_home)
285
+
286
+ existing_state_before = None
287
+ try:
288
+ if is_existing and merge:
289
+ existing_record = store["get_session"](name) or {}
290
+ # replace_session resets the state file to defaults, so the
291
+ # local state must be captured before it runs.
292
+ existing_state_before = store["read_session_state"](name) or {}
293
+ bundle_record = {
294
+ **session_payload,
295
+ "provider": provider,
296
+ "enabled": session_payload.get("enabled", True) is not False,
297
+ "sessionRoot": session_root,
298
+ "authHome": auth_home,
299
+ }
300
+ # Existing values take precedence; bundle fills in missing keys only.
301
+ merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
302
+ merged_record["sessionRoot"] = session_root
303
+ merged_record["authHome"] = auth_home
304
+ store["replace_session"](name, merged_record)
305
+ else:
306
+ session_record = {
307
+ **session_payload,
308
+ "provider": provider,
309
+ "enabled": session_payload.get("enabled", True) is not False,
310
+ "sessionRoot": session_root,
311
+ "authHome": auth_home,
312
+ }
313
+ store["replace_session"](name, session_record)
314
+
315
+ state = (payload.get("states") or {}).get(name)
316
+ if is_existing and merge:
317
+ merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
318
+ if merged_state:
319
+ store["write_session_state"](name, merged_state)
320
+ elif state is not None:
321
+ store["write_session_state"](name, state)
322
+
323
+ for item in decoded_profiles.get(name, []):
324
+ dest_path = os.path.join(session_root, item["path"])
325
+ # In merge mode, skip files that already exist locally.
326
+ if is_existing and merge and os.path.exists(dest_path):
327
+ continue
328
+ _ensure_private_dir(os.path.dirname(dest_path))
329
+ # Decrypted credentials: 0o600 from creation, no umask window.
330
+ atomic_write(dest_path, item["content"], mode=0o600)
331
+ _restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
332
+ except Exception:
333
+ _restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
334
+ raise
335
+ finally:
336
+ if backup_root and os.path.exists(backup_root):
337
+ remove_tree(backup_root, ignore_errors=True)
338
+
339
+ return {
340
+ "path": file_path,
341
+ "session_names": names,
342
+ "include_auth": bool(decoded["meta"].get("include_auth")),
343
+ }
@@ -0,0 +1,179 @@
1
+ """Helpers shared by more than one session-service concern group.
2
+
3
+ Split out of session_service.py ahead of the file split: these are the ten
4
+ helpers the coupling measurement found reachable from two or more groups, plus
5
+ the four constants they read. Anything reached from a single group stays with
6
+ that group. Re-exported by session_service, so callers and tests that name
7
+ them there keep working.
8
+ """
9
+
10
+ import os
11
+ import sys
12
+ from datetime import datetime
13
+ from urllib.parse import quote
14
+
15
+ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS
16
+ from .errors import CdxError
17
+
18
+ DEFAULT_PROVIDER = PROVIDER_CODEX
19
+
20
+ ALLOWED_PROVIDERS = set(PROVIDERS)
21
+
22
+ MAX_SESSION_NAME_LENGTH = 64
23
+
24
+ RESERVED_SESSION_NAMES = {
25
+ "add",
26
+ "can-resume",
27
+ "clean",
28
+ "context",
29
+ "configs",
30
+ "cp",
31
+ "disable",
32
+ "disk",
33
+ "doctor",
34
+ "enable",
35
+ "export",
36
+ "fast",
37
+ "help",
38
+ "handoff",
39
+ "history",
40
+ "import",
41
+ "last",
42
+ "label",
43
+ "login",
44
+ "logout",
45
+ "memory",
46
+ "model",
47
+ "mv",
48
+ "next",
49
+ "notify",
50
+ "perm",
51
+ "power",
52
+ "ready",
53
+ "repair",
54
+ "reset",
55
+ "resume",
56
+ "ren",
57
+ "rename",
58
+ "rmv",
59
+ "run",
60
+ "select",
61
+ "config",
62
+ "set",
63
+ "stats",
64
+ "status",
65
+ "unset",
66
+ "update",
67
+ "view",
68
+ "version",
69
+ "--help",
70
+ "-h",
71
+ "--version",
72
+ "-v",
73
+ }
74
+
75
+ def _encode(name):
76
+ return quote(name, safe="")
77
+
78
+ def _ensure_private_dir(path):
79
+ os.makedirs(path, exist_ok=True)
80
+ if sys.platform == "win32":
81
+ return
82
+ try:
83
+ os.chmod(path, 0o700)
84
+ except OSError:
85
+ pass
86
+
87
+ def _get_global_codex_home(env=None):
88
+ env = env or os.environ
89
+ return env.get("CODEX_HOME") or os.path.join(os.path.expanduser("~"), ".codex")
90
+
91
+ def _local_now_iso():
92
+ return datetime.now().astimezone().isoformat()
93
+
94
+ def _process_is_running(pid):
95
+ try:
96
+ pid = int(pid)
97
+ except (TypeError, ValueError):
98
+ return False
99
+ if pid <= 0:
100
+ return False
101
+ try:
102
+ os.kill(pid, 0)
103
+ except ProcessLookupError:
104
+ return False
105
+ except PermissionError:
106
+ return True
107
+ except OSError:
108
+ return False
109
+ return True
110
+
111
+ def _normalize_provider(provider):
112
+ value = provider or DEFAULT_PROVIDER
113
+ if value not in ALLOWED_PROVIDERS:
114
+ raise CdxError(f"Unsupported provider: {value}")
115
+ return value
116
+
117
+ def _runtime_is_active(runtime):
118
+ return (
119
+ isinstance(runtime, dict)
120
+ and runtime.get("status") == "running"
121
+ and _process_is_running(runtime.get("pid"))
122
+ )
123
+
124
+ def _validate_new_session_name(name):
125
+ if not name:
126
+ raise CdxError("Session name is required")
127
+ if str(name) != str(name).strip():
128
+ raise CdxError("Session name cannot start or end with whitespace")
129
+ if str(name) in (".", ".."):
130
+ raise CdxError("Session name cannot be . or ..")
131
+ if len(str(name)) > MAX_SESSION_NAME_LENGTH:
132
+ raise CdxError(f"Session name is too long (max {MAX_SESSION_NAME_LENGTH} characters)")
133
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in str(name)):
134
+ raise CdxError("Session name cannot contain control characters")
135
+ if name in RESERVED_SESSION_NAMES:
136
+ raise CdxError(f"Session name is reserved: {name}")
137
+
138
+ def _get_session_root(base_dir, name):
139
+ return os.path.join(base_dir, "profiles", _encode(name))
140
+
141
+ def _get_session_auth_home(base_dir, name, provider):
142
+ root = _get_session_root(base_dir, name)
143
+ if provider == PROVIDER_CLAUDE:
144
+ return os.path.join(root, "claude-home")
145
+ if provider == PROVIDER_ANTIGRAVITY:
146
+ return os.path.join(root, "antigravity-home")
147
+ return root
148
+
149
+
150
+ # Store accessors used from more than one concern group. They are entry points
151
+ # of the crud and runtime groups, but status and backup both read through them,
152
+ # so they belong here rather than in either group's module.
153
+ def list_sessions(store):
154
+ return store["list_sessions"]()
155
+
156
+ def _session_runtime(store, name):
157
+ found = {}
158
+
159
+ def updater(state):
160
+ if not state:
161
+ return None
162
+ runtime = state.get("runtime")
163
+ if _runtime_is_active(runtime):
164
+ found["runtime"] = runtime
165
+ return None
166
+ if isinstance(runtime, dict) and runtime.get("status") == "running":
167
+ return {
168
+ **state,
169
+ "status": "ready",
170
+ "runtime": {
171
+ **runtime,
172
+ "status": "stale",
173
+ "endedAt": _local_now_iso(),
174
+ },
175
+ }
176
+ return None
177
+
178
+ store["update_session_state"](name, updater)
179
+ return found.get("runtime")