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.
- package/README.md +214 -19
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +324 -39
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +190 -2
- package/src/cli_render.py +17 -0
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +849 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +713 -0
- package/src/config.py +70 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +142 -15
- package/src/run_command.py +112 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +127 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +698 -1426
- package/src/session_status.py +550 -0
- package/src/status_source.py +45 -0
- package/src/status_view.py +15 -34
package/src/run_registry.py
CHANGED
|
@@ -75,12 +75,38 @@ def _is_pid_alive(pid):
|
|
|
75
75
|
return False
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def _completed_after(run, since):
|
|
79
|
+
"""True when this run finished strictly after `since`.
|
|
80
|
+
|
|
81
|
+
`since` is timezone-aware (the CLI resolves it in local time); stored
|
|
82
|
+
timestamps are UTC with a `Z` suffix. A record with an unparseable or
|
|
83
|
+
missing `ended_at` is treated as not-yet-completed rather than matching, so
|
|
84
|
+
a corrupt row cannot be reported as freshly finished on every poll.
|
|
85
|
+
"""
|
|
86
|
+
ended_at = run.get("ended_at")
|
|
87
|
+
if not ended_at:
|
|
88
|
+
return False
|
|
89
|
+
try:
|
|
90
|
+
ended = datetime.fromisoformat(str(ended_at).replace("Z", "+00:00"))
|
|
91
|
+
except ValueError:
|
|
92
|
+
return False
|
|
93
|
+
if ended.tzinfo is None:
|
|
94
|
+
ended = ended.replace(tzinfo=timezone.utc)
|
|
95
|
+
return ended > since
|
|
96
|
+
|
|
97
|
+
|
|
78
98
|
def _refresh_stale_runs(data):
|
|
79
99
|
now = utc_now_iso()
|
|
80
100
|
changed = False
|
|
81
101
|
for run in data["runs"]:
|
|
82
102
|
if run.get("status") != "running":
|
|
83
103
|
continue
|
|
104
|
+
if run.get("background_path") == "provider_native":
|
|
105
|
+
# The provider owns this agent's lifecycle and the pid it reports
|
|
106
|
+
# belongs to a daemon helper, not the run. Sweeping on pid liveness
|
|
107
|
+
# would mark a healthy delegated run stale; its terminal status
|
|
108
|
+
# comes from the provider instead.
|
|
109
|
+
continue
|
|
84
110
|
pid = run.get("pid")
|
|
85
111
|
if pid and _is_pid_alive(pid):
|
|
86
112
|
continue
|
|
@@ -104,11 +130,31 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
|
|
|
104
130
|
# the liveness proxy until finish() records the provider child's pid.
|
|
105
131
|
"pid": os.getpid(),
|
|
106
132
|
"started_at": utc_now_iso(),
|
|
133
|
+
# A failover run occupies several sessions in turn while staying one
|
|
134
|
+
# run. `session` and `provider` above name the current occupant so every
|
|
135
|
+
# existing reader keeps working; this is the ordered history behind
|
|
136
|
+
# them, and it exists from the start so no run is ever written in a
|
|
137
|
+
# shape the reporting cannot express.
|
|
138
|
+
"occupancies": [{
|
|
139
|
+
"session": session,
|
|
140
|
+
"provider": provider,
|
|
141
|
+
"started_at": utc_now_iso(),
|
|
142
|
+
"ended_at": None,
|
|
143
|
+
"reason": "initial_selection",
|
|
144
|
+
}],
|
|
107
145
|
"ended_at": None,
|
|
108
146
|
"duration_seconds": None,
|
|
109
147
|
"exit_code": None,
|
|
110
148
|
"usage": None,
|
|
111
149
|
"artifacts": dict(artifacts or {}),
|
|
150
|
+
# Which mechanism is carrying a detached run, and the provider's own
|
|
151
|
+
# handle when it is the provider's. Present on every record so a reader
|
|
152
|
+
# never has to infer it from absence.
|
|
153
|
+
"background_path": None,
|
|
154
|
+
# Two handles, because the provider uses two: the session id names the
|
|
155
|
+
# transcript file, the short agent id is what `claude stop` accepts.
|
|
156
|
+
"provider_session_id": None,
|
|
157
|
+
"provider_agent_id": None,
|
|
112
158
|
"error": None,
|
|
113
159
|
"task_report": None,
|
|
114
160
|
"final_payload": None,
|
|
@@ -128,6 +174,68 @@ class RunRegistry:
|
|
|
128
174
|
_write_registry(self.path, data)
|
|
129
175
|
return record
|
|
130
176
|
|
|
177
|
+
def set_pid(self, run_id, pid):
|
|
178
|
+
"""Point a registered run at the process actually doing the work.
|
|
179
|
+
|
|
180
|
+
`start()` records the caller's own pid, which is right when that caller
|
|
181
|
+
stays to supervise. A detached launch does not: it returns immediately,
|
|
182
|
+
so its pid dies within seconds and `_refresh_stale_runs` would find no
|
|
183
|
+
live process, mark the run `stale`, and stamp an `ended_at` — reporting
|
|
184
|
+
a run that is still going as finished, including to `list(since=...)`.
|
|
185
|
+
"""
|
|
186
|
+
with _registry_lock(self.path):
|
|
187
|
+
data = _read_registry(self.path)
|
|
188
|
+
for run in data["runs"]:
|
|
189
|
+
if run.get("run_id") == run_id:
|
|
190
|
+
run["pid"] = pid
|
|
191
|
+
_write_registry(self.path, data)
|
|
192
|
+
return run
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
def record_background(self, run_id, *, path, provider_session_id=None, provider_agent_id=None):
|
|
196
|
+
with _registry_lock(self.path):
|
|
197
|
+
data = _read_registry(self.path)
|
|
198
|
+
for run in data["runs"]:
|
|
199
|
+
if run.get("run_id") != run_id:
|
|
200
|
+
continue
|
|
201
|
+
run["background_path"] = path
|
|
202
|
+
run["provider_session_id"] = provider_session_id
|
|
203
|
+
run["provider_agent_id"] = provider_agent_id
|
|
204
|
+
_write_registry(self.path, data)
|
|
205
|
+
return run
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def migrate(self, run_id, *, session, provider, reason):
|
|
209
|
+
"""Move a still-running run onto another session, keeping one run_id.
|
|
210
|
+
|
|
211
|
+
Closes the current occupancy with the reason it ended and opens the
|
|
212
|
+
next one. The top-level `session`/`provider` follow the new occupant so
|
|
213
|
+
readers that know nothing about occupancies stay correct.
|
|
214
|
+
"""
|
|
215
|
+
with _registry_lock(self.path):
|
|
216
|
+
data = _read_registry(self.path)
|
|
217
|
+
now = utc_now_iso()
|
|
218
|
+
for run in data["runs"]:
|
|
219
|
+
if run.get("run_id") != run_id:
|
|
220
|
+
continue
|
|
221
|
+
occupancies = run.get("occupancies") or []
|
|
222
|
+
if occupancies and occupancies[-1].get("ended_at") is None:
|
|
223
|
+
occupancies[-1]["ended_at"] = now
|
|
224
|
+
occupancies[-1]["reason"] = reason
|
|
225
|
+
occupancies.append({
|
|
226
|
+
"session": session,
|
|
227
|
+
"provider": provider,
|
|
228
|
+
"started_at": now,
|
|
229
|
+
"ended_at": None,
|
|
230
|
+
"reason": "failover_target",
|
|
231
|
+
})
|
|
232
|
+
run["occupancies"] = occupancies
|
|
233
|
+
run["session"] = session
|
|
234
|
+
run["provider"] = provider
|
|
235
|
+
_write_registry(self.path, data)
|
|
236
|
+
return run
|
|
237
|
+
return None
|
|
238
|
+
|
|
131
239
|
def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
|
|
132
240
|
with _registry_lock(self.path):
|
|
133
241
|
return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
|
|
@@ -140,6 +248,10 @@ class RunRegistry:
|
|
|
140
248
|
continue
|
|
141
249
|
run["status"] = status
|
|
142
250
|
run["ended_at"] = now
|
|
251
|
+
occupancies = run.get("occupancies") or []
|
|
252
|
+
if occupancies and occupancies[-1].get("ended_at") is None:
|
|
253
|
+
occupancies[-1]["ended_at"] = now
|
|
254
|
+
occupancies[-1]["reason"] = status
|
|
143
255
|
if run.get("started_at"):
|
|
144
256
|
try:
|
|
145
257
|
start = datetime.fromisoformat(str(run["started_at"]).replace("Z", "+00:00"))
|
|
@@ -167,13 +279,26 @@ class RunRegistry:
|
|
|
167
279
|
return run
|
|
168
280
|
return None
|
|
169
281
|
|
|
170
|
-
def list(self, limit=20):
|
|
282
|
+
def list(self, limit=20, since=None):
|
|
283
|
+
"""Recent runs, newest first.
|
|
284
|
+
|
|
285
|
+
`since` selects every run that *completed* strictly after that moment
|
|
286
|
+
and bypasses `limit` entirely. A cursor caller is asking "what finished
|
|
287
|
+
since I last looked"; capping that by row count would hand back a
|
|
288
|
+
truncated answer that looks complete, which is the silent miss the
|
|
289
|
+
cursor exists to remove. Runs still in flight have no completion time
|
|
290
|
+
and are excluded — they will be returned by a later call, once they
|
|
291
|
+
have actually completed.
|
|
292
|
+
"""
|
|
171
293
|
with _registry_lock(self.path):
|
|
172
294
|
data = _read_registry(self.path)
|
|
173
295
|
changed = _refresh_stale_runs(data)
|
|
174
296
|
if changed:
|
|
175
297
|
_write_registry(self.path, data)
|
|
176
|
-
|
|
298
|
+
runs = data["runs"]
|
|
299
|
+
if since is not None:
|
|
300
|
+
return [run for run in runs if _completed_after(run, since)]
|
|
301
|
+
return runs[: max(0, int(limit or 20))]
|
|
177
302
|
|
|
178
303
|
def get(self, run_id):
|
|
179
304
|
with _registry_lock(self.path):
|
|
@@ -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
|
+
}
|