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