cdx-manager 0.20.8 → 0.20.10
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 +4 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_10.md +71 -0
- package/changelogs/CHANGELOGS_0_20_9.md +39 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +78 -22
- package/src/claude_credentials.py +148 -0
- package/src/claude_usage.py +78 -0
- package/src/commands/backup.py +5 -0
- package/src/commands/runs.py +22 -5
- package/src/context_store.py +22 -3
- package/src/provider_runtime.py +30 -3
- package/src/session_backup.py +117 -23
- package/src/session_service.py +76 -34
- package/src/tray_install.py +86 -14
package/src/commands/runs.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import importlib.util
|
|
6
7
|
import os
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
@@ -147,9 +148,22 @@ def _cdx_self_command():
|
|
|
147
148
|
script has no reliable executable bit on Windows (`os.access(X_OK)` is true
|
|
148
149
|
for any existing file there) and a shebang script handed to CreateProcess
|
|
149
150
|
does not start at all.
|
|
151
|
+
|
|
152
|
+
Named from the top-level package, not this module's own package: `cli` lives
|
|
153
|
+
beside `commands`, so `{__package__}.cli` resolves to a module that does not
|
|
154
|
+
exist. The import root is the parent of every package component, which for
|
|
155
|
+
src/commands/runs.py is the repository root rather than src/.
|
|
150
156
|
"""
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
package = (__package__ or "src.commands").split(".")[0]
|
|
158
|
+
module = f"{package}.cli"
|
|
159
|
+
package_root = os.path.abspath(__file__)
|
|
160
|
+
for _ in range((__package__ or "src.commands").count(".") + 2):
|
|
161
|
+
package_root = os.path.dirname(package_root)
|
|
162
|
+
if importlib.util.find_spec(module) is None:
|
|
163
|
+
# A wrong module name reports a launched run that never runs, so fail
|
|
164
|
+
# here where the caller still gets the error.
|
|
165
|
+
raise CdxError(f"Cannot launch a detached run: the cdx CLI module {module} was not found.")
|
|
166
|
+
return [sys.executable, "-m", module], package_root
|
|
153
167
|
|
|
154
168
|
def _detached_spawn_options():
|
|
155
169
|
"""Platform flags that keep the child alive after this process exits.
|
|
@@ -751,6 +765,7 @@ def handle_run(rest, ctx):
|
|
|
751
765
|
return error.exit_code or 1
|
|
752
766
|
except CdxError as error:
|
|
753
767
|
run_info = getattr(error, "run_info", None)
|
|
768
|
+
cancelled = bool(getattr(error, "cancelled", False))
|
|
754
769
|
final_payload = run_result_payload(
|
|
755
770
|
API_SCHEMA_VERSION,
|
|
756
771
|
False,
|
|
@@ -758,14 +773,16 @@ def handle_run(rest, ctx):
|
|
|
758
773
|
locals().get("session"),
|
|
759
774
|
run_info=run_info,
|
|
760
775
|
error=error,
|
|
761
|
-
error_source="cdx",
|
|
762
|
-
error_code=run_cdx_error_code(error),
|
|
776
|
+
error_source="provider" if cancelled else "cdx",
|
|
777
|
+
error_code="run_cancelled" if cancelled else run_cdx_error_code(error),
|
|
763
778
|
selection_row=selection_row,
|
|
764
779
|
)
|
|
765
780
|
if registry and run_id:
|
|
766
781
|
registry.finish(
|
|
767
782
|
run_id,
|
|
768
|
-
|
|
783
|
+
# A cancelled run is neither a task failure nor a timeout, and a
|
|
784
|
+
# caller has to be able to tell the three apart.
|
|
785
|
+
status="cancelled" if cancelled else "failed",
|
|
769
786
|
final_payload=final_payload,
|
|
770
787
|
run_info=run_info,
|
|
771
788
|
error=final_payload.get("error"),
|
package/src/context_store.py
CHANGED
|
@@ -2,6 +2,7 @@ import hashlib
|
|
|
2
2
|
import os
|
|
3
3
|
import shlex
|
|
4
4
|
import subprocess
|
|
5
|
+
import threading
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from urllib.parse import quote, unquote
|
|
7
8
|
|
|
@@ -105,13 +106,31 @@ def append_context(base_dir, note, cwd=None):
|
|
|
105
106
|
return append_context_path(get_context_path(base_dir, cwd), note)
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
# An append is a read-modify-write, and the write is atomic per attempt, so two
|
|
110
|
+
# concurrent appends that both read the pre-existing text produce one file that
|
|
111
|
+
# is missing a note the caller was told had been accepted. Parallel agents append
|
|
112
|
+
# to the same scope by design, so the sequence is serialized here.
|
|
113
|
+
_APPEND_LOCKS = {}
|
|
114
|
+
_APPEND_LOCKS_GUARD = threading.Lock()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _append_lock(path):
|
|
118
|
+
key = os.path.abspath(path)
|
|
119
|
+
with _APPEND_LOCKS_GUARD:
|
|
120
|
+
lock = _APPEND_LOCKS.get(key)
|
|
121
|
+
if lock is None:
|
|
122
|
+
lock = _APPEND_LOCKS[key] = threading.Lock()
|
|
123
|
+
return lock
|
|
124
|
+
|
|
125
|
+
|
|
108
126
|
def append_context_path(path, note):
|
|
109
127
|
note = str(note or "").strip()
|
|
110
128
|
if not note:
|
|
111
129
|
raise CdxError("Memory append requires text.")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
with _append_lock(path):
|
|
131
|
+
current = read_context_path(path).rstrip()
|
|
132
|
+
content = f"{current}\n{note}" if current else note
|
|
133
|
+
return write_context_path(path, content)
|
|
115
134
|
|
|
116
135
|
|
|
117
136
|
def init_context(base_dir, cwd=None):
|
package/src/provider_runtime.py
CHANGED
|
@@ -12,7 +12,8 @@ import uuid
|
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
|
|
14
14
|
from .agent_notify import launch_notify_env, notifications_enabled
|
|
15
|
-
from .
|
|
15
|
+
from .claude_credentials import peek_keychain_credentials
|
|
16
|
+
from .claude_usage import _clean_oauth_token, _decode_jwt_claims, _secure_storage_overrides
|
|
16
17
|
from .codex_usage import codex_auth_lock, diagnostic_needs_codex_login, fetch_codex_rate_limit_diagnostic
|
|
17
18
|
from .config import (
|
|
18
19
|
PROVIDER_ANTIGRAVITY,
|
|
@@ -145,7 +146,9 @@ def _home_env_overrides(auth_home):
|
|
|
145
146
|
Code builds ignore otherwise valid isolated credentials. On Windows, Node.js
|
|
146
147
|
resolves the home directory via USERPROFILE (and falls back to
|
|
147
148
|
HOMEDRIVE+HOMEPATH), so we set all three to ensure profile isolation works
|
|
148
|
-
regardless of the platform.
|
|
149
|
+
regardless of the platform. Redirecting the home also detaches Claude
|
|
150
|
+
Code's credential storage, which `_secure_storage_overrides` re-keys to
|
|
151
|
+
this profile.
|
|
149
152
|
"""
|
|
150
153
|
overrides = {
|
|
151
154
|
"HOME": auth_home,
|
|
@@ -156,6 +159,7 @@ def _home_env_overrides(auth_home):
|
|
|
156
159
|
overrides["USERPROFILE"] = auth_home
|
|
157
160
|
overrides["HOMEDRIVE"] = os.path.splitdrive(auth_home)[0] or "C:"
|
|
158
161
|
overrides["HOMEPATH"] = os.path.splitdrive(auth_home)[1] or auth_home
|
|
162
|
+
overrides.update(_secure_storage_overrides(auth_home))
|
|
159
163
|
return overrides
|
|
160
164
|
|
|
161
165
|
|
|
@@ -229,7 +233,10 @@ def _has_local_claude_auth(auth_home):
|
|
|
229
233
|
def _read_claude_launch_oauth_token(auth_home):
|
|
230
234
|
if _has_local_claude_auth(auth_home):
|
|
231
235
|
return None
|
|
232
|
-
|
|
236
|
+
token = _read_anthropic_oauth_token(auth_home)
|
|
237
|
+
if token and peek_keychain_credentials(auth_home) is not None:
|
|
238
|
+
return None
|
|
239
|
+
return token
|
|
233
240
|
|
|
234
241
|
|
|
235
242
|
def _has_local_codex_auth(auth_home):
|
|
@@ -1016,6 +1023,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1016
1023
|
|
|
1017
1024
|
child = None
|
|
1018
1025
|
timed_out = False
|
|
1026
|
+
cancelled = False
|
|
1019
1027
|
with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
|
|
1020
1028
|
open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
|
|
1021
1029
|
options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
|
|
@@ -1049,6 +1057,25 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1049
1057
|
except subprocess.TimeoutExpired:
|
|
1050
1058
|
timed_out = True
|
|
1051
1059
|
_terminate_child_tree(child)
|
|
1060
|
+
except KeyboardInterrupt:
|
|
1061
|
+
# The provider has its own process group, so unwinding only this
|
|
1062
|
+
# supervisor would leave it consuming quota and writing to the
|
|
1063
|
+
# workspace. Terminate and reap it, then report the cancellation so
|
|
1064
|
+
# the run still gets a terminal record.
|
|
1065
|
+
cancelled = True
|
|
1066
|
+
_terminate_child_tree(child)
|
|
1067
|
+
|
|
1068
|
+
if cancelled:
|
|
1069
|
+
_combine_headless_transcript(paths)
|
|
1070
|
+
returncode = getattr(child, "returncode", None)
|
|
1071
|
+
cdx_error = CdxError(f"{spec['label']} run was cancelled.", 130)
|
|
1072
|
+
cdx_error.cancelled = True
|
|
1073
|
+
cdx_error.run_info = {
|
|
1074
|
+
**_headless_run_info(paths, spec, start_time, 130 if returncode in (None, 0) else returncode),
|
|
1075
|
+
"pid": getattr(child, "pid", None),
|
|
1076
|
+
"cancelled": True,
|
|
1077
|
+
}
|
|
1078
|
+
raise cdx_error
|
|
1052
1079
|
|
|
1053
1080
|
_combine_headless_transcript(paths)
|
|
1054
1081
|
end_time = datetime.now(timezone.utc)
|
package/src/session_backup.py
CHANGED
|
@@ -6,11 +6,17 @@ coupling measurement supports. Moved verbatim; re-exported by session_service.
|
|
|
6
6
|
|
|
7
7
|
import base64
|
|
8
8
|
import binascii
|
|
9
|
+
import json
|
|
9
10
|
import os
|
|
10
11
|
import shutil
|
|
11
12
|
import tempfile
|
|
12
13
|
|
|
13
14
|
from .backup_bundle import decode_bundle, encode_bundle
|
|
15
|
+
from .claude_credentials import (
|
|
16
|
+
delete_keychain_credentials,
|
|
17
|
+
read_keychain_credentials,
|
|
18
|
+
write_keychain_credentials,
|
|
19
|
+
)
|
|
14
20
|
from .config import PROVIDER_CLAUDE
|
|
15
21
|
from .errors import CdxError
|
|
16
22
|
from .fs_utils import atomic_write, remove_tree
|
|
@@ -55,12 +61,14 @@ def _build_export_session_record(session):
|
|
|
55
61
|
"auth": session.get("auth"),
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
CLAUDE_CREDENTIALS_BUNDLE_PATH = "claude-home/.claude/.credentials.json"
|
|
65
|
+
|
|
58
66
|
def _auth_bundle_paths(provider):
|
|
59
67
|
if provider == PROVIDER_CLAUDE:
|
|
60
68
|
return [
|
|
61
69
|
"claude-home/configs/default.json",
|
|
62
70
|
"claude-home/credentials/default.json",
|
|
63
|
-
|
|
71
|
+
CLAUDE_CREDENTIALS_BUNDLE_PATH,
|
|
64
72
|
"claude-home/.claude.json",
|
|
65
73
|
"claude-home/auth.json",
|
|
66
74
|
]
|
|
@@ -68,19 +76,26 @@ def _auth_bundle_paths(provider):
|
|
|
68
76
|
"auth.json",
|
|
69
77
|
]
|
|
70
78
|
|
|
71
|
-
def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
|
|
79
|
+
def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None, auth_home=None):
|
|
72
80
|
files = []
|
|
73
81
|
total_bytes = 0
|
|
74
82
|
if not os.path.isdir(session_root):
|
|
75
83
|
return {"files": files, "file_count": 0, "bytes": 0}
|
|
84
|
+
# A keychain-backed profile has no credential file to read. Export the entry
|
|
85
|
+
# under the path Claude Code itself uses, which is the same document: the
|
|
86
|
+
# bundle stays one schema, and an import restores a usable login.
|
|
87
|
+
keychain = read_keychain_credentials(auth_home) if provider == PROVIDER_CLAUDE and auth_home else None
|
|
76
88
|
for rel_path in _auth_bundle_paths(provider):
|
|
77
89
|
full_path = os.path.join(session_root, rel_path)
|
|
78
|
-
if not
|
|
90
|
+
if keychain is not None and rel_path == CLAUDE_CREDENTIALS_BUNDLE_PATH:
|
|
91
|
+
raw_content = json.dumps(keychain).encode("utf-8")
|
|
92
|
+
elif os.path.isfile(full_path):
|
|
93
|
+
with open(full_path, "rb") as handle:
|
|
94
|
+
raw_content = handle.read()
|
|
95
|
+
else:
|
|
79
96
|
continue
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
total_bytes += len(raw_content)
|
|
83
|
-
content = base64.b64encode(raw_content).decode("ascii")
|
|
97
|
+
total_bytes += len(raw_content)
|
|
98
|
+
content = base64.b64encode(raw_content).decode("ascii")
|
|
84
99
|
files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
|
|
85
100
|
if progress_callback:
|
|
86
101
|
progress_callback({
|
|
@@ -149,9 +164,22 @@ def _resolve_session_subset(store, session_names):
|
|
|
149
164
|
selected.append(session)
|
|
150
165
|
return selected
|
|
151
166
|
|
|
167
|
+
def _restore_import_credential(auth_home, credential):
|
|
168
|
+
"""Put the destination's own Claude credential back after a failed import.
|
|
169
|
+
|
|
170
|
+
The snapshot is held in memory for the length of the import rather than
|
|
171
|
+
written beside the profile: a rollback only has to survive this process, and
|
|
172
|
+
a plaintext token in a temporary directory would be a worse trade.
|
|
173
|
+
"""
|
|
174
|
+
if credential is None:
|
|
175
|
+
return
|
|
176
|
+
if read_keychain_credentials(auth_home) == credential:
|
|
177
|
+
return
|
|
178
|
+
write_keychain_credentials(auth_home, credential)
|
|
179
|
+
|
|
152
180
|
def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
|
|
153
181
|
if os.path.exists(session_root):
|
|
154
|
-
remove_tree(session_root
|
|
182
|
+
remove_tree(session_root)
|
|
155
183
|
if backup_root and os.path.exists(backup_root):
|
|
156
184
|
os.rename(backup_root, session_root)
|
|
157
185
|
if old_record:
|
|
@@ -194,7 +222,12 @@ def export_bundle(base_dir, store, file_path, include_auth=False, session_names=
|
|
|
194
222
|
payload["states"][session["name"]] = state
|
|
195
223
|
if include_auth:
|
|
196
224
|
session_root = session.get("sessionRoot") or _get_session_root(base_dir, session["name"])
|
|
197
|
-
|
|
225
|
+
auth_home = session.get("authHome") or _get_session_auth_home(base_dir, session["name"], session["provider"])
|
|
226
|
+
try:
|
|
227
|
+
profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback,
|
|
228
|
+
auth_home=auth_home)
|
|
229
|
+
except CdxError as error:
|
|
230
|
+
raise CdxError(f"Cannot export authentication for {session['name']}: {error}. Nothing was exported.") from None
|
|
198
231
|
payload["profiles"][session["name"]] = profile["files"]
|
|
199
232
|
profile_file_count += profile["file_count"]
|
|
200
233
|
profile_bytes += profile["bytes"]
|
|
@@ -242,6 +275,7 @@ def import_bundle(base_dir, store, file_path,
|
|
|
242
275
|
if missing_names:
|
|
243
276
|
raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
|
|
244
277
|
names = [item["name"] for item in imported_sessions]
|
|
278
|
+
retained_credential_names = []
|
|
245
279
|
|
|
246
280
|
existing = {session["name"] for session in list_sessions(store)}
|
|
247
281
|
conflicts = [name for name in names if name in existing]
|
|
@@ -268,23 +302,50 @@ def import_bundle(base_dir, store, file_path,
|
|
|
268
302
|
_ensure_private_dir(base_dir)
|
|
269
303
|
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
270
304
|
backup_root = None
|
|
305
|
+
recovery_dir = None
|
|
271
306
|
old_record = None
|
|
272
307
|
old_state = None
|
|
273
308
|
preserved_profile_paths = []
|
|
274
|
-
if is_existing and force:
|
|
309
|
+
if is_existing and (force or merge):
|
|
275
310
|
old_record = store["get_session"](name)
|
|
276
311
|
old_state = store["read_session_state"](name)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
312
|
+
if merge:
|
|
313
|
+
provider = old_record["provider"]
|
|
314
|
+
auth_home = old_record["authHome"]
|
|
315
|
+
recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
316
|
+
backup_root = os.path.join(recovery_dir, "profile")
|
|
317
|
+
try:
|
|
318
|
+
atomic_write(os.path.join(recovery_dir, "recovery.json"),
|
|
319
|
+
json.dumps({"session": old_record, "state": old_state}), mode=0o600)
|
|
320
|
+
if force:
|
|
321
|
+
preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
|
|
322
|
+
if os.path.exists(session_root):
|
|
323
|
+
os.rename(session_root, backup_root)
|
|
324
|
+
is_existing = False
|
|
325
|
+
elif os.path.exists(session_root):
|
|
326
|
+
# ponytail: full profile snapshot; journal new merge files if copy cost becomes material.
|
|
327
|
+
# Keep links as links: a Claude profile can reference the system keychain,
|
|
328
|
+
# and a dangling one (stale plugin or npm cache) must not fail the import.
|
|
329
|
+
shutil.copytree(session_root, backup_root, symlinks=True, ignore_dangling_symlinks=True)
|
|
330
|
+
except Exception:
|
|
331
|
+
remove_tree(recovery_dir, ignore_errors=True)
|
|
332
|
+
raise
|
|
333
|
+
|
|
334
|
+
# A keychain-only profile has no credential file, so the collision with a
|
|
335
|
+
# bundle credential exists at the credential-backend level and has to be
|
|
336
|
+
# resolved there. Read it strictly: refusing an unreadable keychain is
|
|
337
|
+
# better than importing over a credential we could not preserve.
|
|
338
|
+
bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
|
|
339
|
+
item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
|
|
340
|
+
)
|
|
341
|
+
local_credential = None
|
|
342
|
+
if bundle_carries_credential and old_record is not None:
|
|
343
|
+
local_credential = read_keychain_credentials(auth_home)
|
|
285
344
|
|
|
286
345
|
existing_state_before = None
|
|
287
346
|
try:
|
|
347
|
+
_ensure_private_dir(session_root)
|
|
348
|
+
_ensure_private_dir(auth_home)
|
|
288
349
|
if is_existing and merge:
|
|
289
350
|
existing_record = store["get_session"](name) or {}
|
|
290
351
|
# replace_session resets the state file to defaults, so the
|
|
@@ -320,24 +381,57 @@ def import_bundle(base_dir, store, file_path,
|
|
|
320
381
|
elif state is not None:
|
|
321
382
|
store["write_session_state"](name, state)
|
|
322
383
|
|
|
384
|
+
imported_paths = []
|
|
323
385
|
for item in decoded_profiles.get(name, []):
|
|
324
386
|
dest_path = os.path.join(session_root, item["path"])
|
|
387
|
+
# A syntactically relative path can still escape through a profile symlink.
|
|
388
|
+
root = os.path.realpath(session_root)
|
|
389
|
+
try:
|
|
390
|
+
inside = os.path.commonpath([root, os.path.realpath(dest_path)]) == root
|
|
391
|
+
except ValueError: # different drive: never inside the profile.
|
|
392
|
+
inside = False
|
|
393
|
+
if not inside:
|
|
394
|
+
raise CdxError("Bundle file path escapes the session profile through a link.")
|
|
325
395
|
# In merge mode, skip files that already exist locally.
|
|
326
|
-
if is_existing and merge and os.path.
|
|
396
|
+
if is_existing and merge and os.path.lexists(dest_path):
|
|
397
|
+
continue
|
|
398
|
+
if merge and local_credential is not None and item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH:
|
|
399
|
+
# Merge keeps local values, and the live keychain entry is this
|
|
400
|
+
# profile's credential even though no file represents it.
|
|
401
|
+
retained_credential_names.append(name)
|
|
327
402
|
continue
|
|
328
403
|
_ensure_private_dir(os.path.dirname(dest_path))
|
|
329
404
|
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
330
405
|
atomic_write(dest_path, item["content"], mode=0o600)
|
|
406
|
+
imported_paths.append(item["path"])
|
|
407
|
+
if provider == PROVIDER_CLAUDE and CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths:
|
|
408
|
+
delete_keychain_credentials(auth_home)
|
|
331
409
|
_restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
|
|
332
410
|
except Exception:
|
|
333
|
-
|
|
411
|
+
incomplete = []
|
|
412
|
+
try:
|
|
413
|
+
_restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
|
|
414
|
+
except Exception:
|
|
415
|
+
incomplete.append("local files, the session record, and state")
|
|
416
|
+
try:
|
|
417
|
+
_restore_import_credential(auth_home, local_credential)
|
|
418
|
+
except Exception:
|
|
419
|
+
incomplete.append("Claude keychain authentication")
|
|
420
|
+
if incomplete:
|
|
421
|
+
raise CdxError(
|
|
422
|
+
f"Import failed and recovery is incomplete for {name}: could not restore "
|
|
423
|
+
f"{' and '.join(incomplete)}. Recovery data retained at {recovery_dir or session_root}."
|
|
424
|
+
) from None
|
|
425
|
+
if recovery_dir:
|
|
426
|
+
remove_tree(recovery_dir, ignore_errors=True)
|
|
334
427
|
raise
|
|
335
|
-
|
|
336
|
-
if
|
|
337
|
-
remove_tree(
|
|
428
|
+
else:
|
|
429
|
+
if recovery_dir:
|
|
430
|
+
remove_tree(recovery_dir, ignore_errors=True)
|
|
338
431
|
|
|
339
432
|
return {
|
|
340
433
|
"path": file_path,
|
|
341
434
|
"session_names": names,
|
|
342
435
|
"include_auth": bool(decoded["meta"].get("include_auth")),
|
|
436
|
+
"retained_local_credentials": retained_credential_names,
|
|
343
437
|
}
|
package/src/session_service.py
CHANGED
|
@@ -31,8 +31,11 @@ import os
|
|
|
31
31
|
import shutil
|
|
32
32
|
import tempfile
|
|
33
33
|
import uuid
|
|
34
|
+
from contextlib import nullcontext
|
|
34
35
|
from functools import partial
|
|
35
36
|
|
|
37
|
+
from .claude_credentials import copy_keychain_credentials, delete_keychain_credentials
|
|
38
|
+
from .claude_usage import _link_macos_keychain
|
|
36
39
|
from .codex_usage import fetch_codex_rate_limits
|
|
37
40
|
from .config import (
|
|
38
41
|
MAX_LAUNCH_BUDGET_USD,
|
|
@@ -471,6 +474,22 @@ def set_session_enabled(store, name, enabled):
|
|
|
471
474
|
})
|
|
472
475
|
|
|
473
476
|
|
|
477
|
+
def _discard_claude_credentials(record, done):
|
|
478
|
+
"""Drop a gone profile's keychain entry.
|
|
479
|
+
|
|
480
|
+
Left behind it both keeps the OAuth token alive and blocks the name: the
|
|
481
|
+
next `cdx cp`/`cdx ren` into it refuses to overwrite an entry no session
|
|
482
|
+
owns any more. The profile is already gone when this runs, so a failure
|
|
483
|
+
is reported without undoing the operation.
|
|
484
|
+
"""
|
|
485
|
+
if not record or record.get("provider") != PROVIDER_CLAUDE:
|
|
486
|
+
return
|
|
487
|
+
try:
|
|
488
|
+
delete_keychain_credentials(record.get("authHome"))
|
|
489
|
+
except CdxError as error:
|
|
490
|
+
raise CdxError(f"{done}, but its Claude keychain credential could not be deleted: {error}") from None
|
|
491
|
+
|
|
492
|
+
|
|
474
493
|
def remove_session(base_dir, store, name):
|
|
475
494
|
session = store["get_session"](name)
|
|
476
495
|
if not session:
|
|
@@ -500,6 +519,7 @@ def remove_session(base_dir, store, name):
|
|
|
500
519
|
raise CdxError(
|
|
501
520
|
f"Removed session {name}, but failed to delete archived profile {quarantine_root}: {error}"
|
|
502
521
|
) from error
|
|
522
|
+
_discard_claude_credentials(session, f"Removed session {name}")
|
|
503
523
|
return removed
|
|
504
524
|
|
|
505
525
|
|
|
@@ -517,6 +537,7 @@ def copy_session(base_dir, store, source_name, dest_name):
|
|
|
517
537
|
if not existing and os.path.exists(dest_root):
|
|
518
538
|
raise CdxError(f"Session profile already exists: {dest_name}")
|
|
519
539
|
dest_auth_home = _get_session_auth_home(base_dir, dest_name, source["provider"])
|
|
540
|
+
source_auth_home = source.get("authHome") or _get_session_auth_home(base_dir, source_name, source["provider"])
|
|
520
541
|
profiles_dir = os.path.dirname(dest_root)
|
|
521
542
|
os.makedirs(profiles_dir, exist_ok=True)
|
|
522
543
|
temp_parent = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.copy.", dir=profiles_dir)
|
|
@@ -545,28 +566,44 @@ def copy_session(base_dir, store, source_name, dest_name):
|
|
|
545
566
|
},
|
|
546
567
|
}
|
|
547
568
|
try:
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
569
|
+
credential_copy = copy_keychain_credentials(
|
|
570
|
+
source_auth_home, dest_auth_home,
|
|
571
|
+
overwrite=bool(existing and existing["provider"] == PROVIDER_CLAUDE),
|
|
572
|
+
) if source["provider"] == PROVIDER_CLAUDE else nullcontext()
|
|
573
|
+
with credential_copy:
|
|
574
|
+
if source["provider"] == PROVIDER_CLAUDE:
|
|
575
|
+
shutil.copytree(source_root, temp_root, symlinks=True,
|
|
576
|
+
ignore=lambda _directory, names: [n for n in names if n == "Keychains"])
|
|
577
|
+
_link_macos_keychain(os.path.join(temp_root, "claude-home"))
|
|
578
|
+
else:
|
|
579
|
+
shutil.copytree(source_root, temp_root)
|
|
580
|
+
if existing:
|
|
581
|
+
backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
|
|
582
|
+
os.rmdir(backup_root)
|
|
583
|
+
if os.path.exists(dest_root):
|
|
584
|
+
os.rename(dest_root, backup_root)
|
|
585
|
+
os.rename(temp_root, dest_root)
|
|
586
|
+
moved_temp = True
|
|
587
|
+
result = store["replace_session"](dest_name, replacement)
|
|
588
|
+
if not result["ok"]:
|
|
589
|
+
raise CdxError(f"Failed to create session: {dest_name}")
|
|
590
|
+
overwritten = bool(existing)
|
|
591
|
+
if existing and source["provider"] != PROVIDER_CLAUDE:
|
|
592
|
+
_discard_claude_credentials(existing, f"Replaced session {dest_name}")
|
|
558
593
|
except Exception:
|
|
559
594
|
if moved_temp and os.path.exists(dest_root):
|
|
560
595
|
remove_tree(dest_root, ignore_errors=True)
|
|
561
|
-
if backup_root and os.path.exists(backup_root)
|
|
562
|
-
os.
|
|
596
|
+
if backup_root and os.path.exists(backup_root):
|
|
597
|
+
if not os.path.exists(dest_root):
|
|
598
|
+
os.rename(backup_root, dest_root)
|
|
599
|
+
else:
|
|
600
|
+
raise CdxError(f"Copy to {dest_name} failed and its previous profile was retained at {backup_root}.") from None
|
|
563
601
|
raise
|
|
564
|
-
|
|
602
|
+
else:
|
|
565
603
|
if backup_root and os.path.exists(backup_root):
|
|
566
604
|
remove_tree(backup_root, ignore_errors=True)
|
|
605
|
+
finally:
|
|
567
606
|
remove_tree(temp_parent, ignore_errors=True)
|
|
568
|
-
if not result["ok"]:
|
|
569
|
-
raise CdxError(f"Failed to create session: {dest_name}")
|
|
570
607
|
return {"session": result["session"], "overwritten": overwritten}
|
|
571
608
|
|
|
572
609
|
|
|
@@ -585,32 +622,37 @@ def rename_session(base_dir, store, source_name, dest_name):
|
|
|
585
622
|
if os.path.exists(dest_root):
|
|
586
623
|
raise CdxError(f"Session profile already exists: {dest_name}")
|
|
587
624
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
else:
|
|
592
|
-
moved_profile = False
|
|
593
|
-
|
|
625
|
+
moved_profile = False
|
|
626
|
+
dest_auth_home = _get_session_auth_home(base_dir, dest_name, source["provider"])
|
|
627
|
+
source_auth_home = source.get("authHome") or _get_session_auth_home(base_dir, source_name, source["provider"])
|
|
594
628
|
now = _local_now_iso()
|
|
595
629
|
try:
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
"
|
|
602
|
-
|
|
630
|
+
credential_copy = copy_keychain_credentials(source_auth_home, dest_auth_home) if source["provider"] == PROVIDER_CLAUDE else nullcontext(False)
|
|
631
|
+
with credential_copy as copied_credential:
|
|
632
|
+
if os.path.exists(source_root):
|
|
633
|
+
os.rename(source_root, dest_root)
|
|
634
|
+
moved_profile = True
|
|
635
|
+
result = store["rename_session"](source_name, dest_name, lambda s: {
|
|
636
|
+
**s,
|
|
637
|
+
"name": dest_name,
|
|
638
|
+
"sessionRoot": dest_root,
|
|
639
|
+
"authHome": dest_auth_home,
|
|
640
|
+
"updatedAt": now,
|
|
641
|
+
})
|
|
642
|
+
if not result["ok"]:
|
|
643
|
+
if result["reason"] == "exists":
|
|
644
|
+
raise CdxError(f"Session already exists: {dest_name}")
|
|
645
|
+
raise CdxError(f"Unknown session: {source_name}")
|
|
603
646
|
except Exception:
|
|
604
647
|
if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
|
|
605
648
|
os.rename(dest_root, source_root)
|
|
606
649
|
raise
|
|
607
650
|
|
|
608
|
-
if
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
raise CdxError(f"
|
|
613
|
-
raise CdxError(f"Unknown session: {source_name}")
|
|
651
|
+
if copied_credential:
|
|
652
|
+
try:
|
|
653
|
+
delete_keychain_credentials(source_auth_home)
|
|
654
|
+
except CdxError:
|
|
655
|
+
raise CdxError(f"Renamed session to {dest_name}, but old keychain credential cleanup failed; the new session is usable and the old entry was retained.") from None
|
|
614
656
|
return result["session"]
|
|
615
657
|
|
|
616
658
|
|