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.
@@ -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
- package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
152
- return [sys.executable, "-m", f"{__package__}.cli"], package_root
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
- status="failed",
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"),
@@ -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
- current = read_context_path(path).rstrip()
113
- content = f"{current}\n{note}" if current else note
114
- return write_context_path(path, content)
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):
@@ -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 .claude_usage import _clean_oauth_token, _decode_jwt_claims
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
- return _read_anthropic_oauth_token(auth_home)
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)
@@ -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
- "claude-home/.claude/.credentials.json",
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 os.path.isfile(full_path):
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
- with open(full_path, "rb") as handle:
81
- raw_content = handle.read()
82
- total_bytes += len(raw_content)
83
- content = base64.b64encode(raw_content).decode("ascii")
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, ignore_errors=True)
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
- profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback)
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
- preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
278
- if os.path.exists(session_root):
279
- backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
280
- os.rmdir(backup_root)
281
- os.rename(session_root, backup_root)
282
- is_existing = False
283
- _ensure_private_dir(session_root)
284
- _ensure_private_dir(auth_home)
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.exists(dest_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
- _restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
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
- finally:
336
- if backup_root and os.path.exists(backup_root):
337
- remove_tree(backup_root, ignore_errors=True)
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
  }
@@ -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
- shutil.copytree(source_root, temp_root)
549
- if existing:
550
- backup_root = tempfile.mkdtemp(prefix=f".{_encode(dest_name)}.backup.", dir=profiles_dir)
551
- os.rmdir(backup_root)
552
- if os.path.exists(dest_root):
553
- os.rename(dest_root, backup_root)
554
- os.rename(temp_root, dest_root)
555
- moved_temp = True
556
- result = store["replace_session"](dest_name, replacement)
557
- overwritten = bool(existing)
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) and not os.path.exists(dest_root):
562
- os.rename(backup_root, dest_root)
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
- finally:
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
- if os.path.exists(source_root):
589
- os.rename(source_root, dest_root)
590
- moved_profile = True
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
- result = store["rename_session"](source_name, dest_name, lambda s: {
597
- **s,
598
- "name": dest_name,
599
- "sessionRoot": dest_root,
600
- "authHome": _get_session_auth_home(base_dir, dest_name, s["provider"]),
601
- "updatedAt": now,
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 not result["ok"]:
609
- if moved_profile and os.path.exists(dest_root) and not os.path.exists(source_root):
610
- os.rename(dest_root, source_root)
611
- if result["reason"] == "exists":
612
- raise CdxError(f"Session already exists: {dest_name}")
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