cdx-manager 0.20.9 → 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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.20.9-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.20.10-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
10
10
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.20.9
1
+ 0.20.10
@@ -0,0 +1,71 @@
1
+ # CDX Manager 0.20.10
2
+
3
+ ## Fixes
4
+
5
+ - Keep the local account when `cdx import --merge` brings in a bundle that
6
+ carries authentication. A keychain-backed profile has no credential file, so
7
+ the collision was never noticed and the local login was replaced and its
8
+ keychain entry deleted by an operation whose whole promise is to keep local
9
+ values. The collision is now resolved against the keychain itself, and the
10
+ import says which sessions kept their existing Claude authentication.
11
+ - Restore the destination's Claude credential when `cdx import --force` fails
12
+ after staging the imported one. Filesystem rollback already worked; the
13
+ keychain entry did not come back, which lost the profile's only login. It is
14
+ snapshotted before anything can clear it and restored on failure, and a
15
+ recovery that cannot complete now names Claude keychain authentication
16
+ instead of reporting a generic incomplete recovery.
17
+ - Decode encrypted bundles with the key derivation function their exporter
18
+ recorded. A bundle written on a Python without `hashlib.scrypt` stopped
19
+ opening on a Python that has it, with the same passphrase, because decoding
20
+ re-chose the derivation from the importing runtime. A derivation this runtime
21
+ cannot compute is now reported as such rather than as a wrong passphrase, and
22
+ older bundles that recorded nothing usable stay readable.
23
+ - Keep every accepted note when several agents append to the same `cdx memory`
24
+ scope at once. The append read the scope and wrote it back with nothing
25
+ covering the pair, so both callers were told their note was accepted and one
26
+ of them was not there.
27
+ - Make `cdx run --detach` actually run. The child was asked for a CLI module
28
+ that does not exist, so the launcher reported a run id for work that never
29
+ started and never cleaned up its staged prompt. A module name that does not
30
+ resolve now fails the launch instead of being reported as launched.
31
+ - Terminate the provider when a headless run is interrupted. Ctrl-C unwound the
32
+ supervising CLI and left the provider running in its own process group, still
33
+ consuming quota and writing to the working tree, while the run record
34
+ described the supervisor. The provider tree is now terminated and reaped, and
35
+ the run is recorded as `cancelled` with exit code 130, which a caller can tell
36
+ apart from a failure and from a timeout.
37
+ - Keep a working tray when replacing the companion fails midway. A filesystem
38
+ error between retiring the installed companion and promoting the staged one
39
+ left the install record pointing at a path with nothing in it, and skipped the
40
+ restart of the tray that had just been stopped. The proven companion is put
41
+ back and the tray is restarted on it.
42
+ - Keep the Windows Start Menu shortcut pointing at the companion that is
43
+ actually installed. It was written against the staged executable, which the
44
+ update then moved, and the promoted install record dropped it, so uninstall
45
+ stopped removing it. It is now written by the promotion against the final
46
+ path and stays owned by uninstall.
47
+ - Make the macOS pre-promotion launch check real. `--print` was handed to the
48
+ `open` utility rather than to the companion and the exit status was ignored,
49
+ so the gate passed for an app bundle that never started. It now runs the
50
+ binary inside the bundle, refuses a bundle with nothing runnable in it, and
51
+ still accepts a companion that started and reported a problem of its own.
52
+ - Open the intended command when a Windows tray reaches CDX through WSL.
53
+ A session's launch settings row sent `config <session>` as a single argument,
54
+ so CDX saw one session name with a space in it and refused it.
55
+ - Offer only terminals a Windows tray can open. The candidate list is built by
56
+ whichever host answers the poll, which under WSL is Linux, so the menu could
57
+ offer a Linux terminal and then open nothing when it was chosen. The
58
+ companion now lists what it can launch, and a stored preference it cannot
59
+ open falls back to the default console instead of an inert click.
60
+ - Run the selected command when the tray terminal preference is `cmd`. It was
61
+ given PowerShell's `-NoExit -Command`, which `cmd.exe` accepts without
62
+ running anything, so the click opened an idle shell.
63
+
64
+ ## Notes
65
+
66
+ - A cancelled headless run is a new terminal state: `cancelled`, alongside
67
+ `succeeded`, `failed` and `timed_out`. Callers that branch on run status
68
+ should treat it as an outcome rather than as an unknown value.
69
+ - Bundles exported by this version record and honour their key derivation
70
+ function. Bundles exported by earlier versions are unaffected and stay
71
+ readable.
@@ -408,6 +408,17 @@
408
408
  "x86_64-pc-windows-msvc": "343245a6ce52275e8e7bd9a380d9e03fd1db3bcf917df3e6cecde2d0be1bfaeb",
409
409
  "x86_64-unknown-linux-musl": "5d6e776b5ce64a062c798c5c00f9e85e003b5b6888cece74173bff1f45247f34"
410
410
  }
411
+ },
412
+ "v0.20.9": {
413
+ "github_tarball_sha256": "1d1989861ba26df76ee3b1d0db98fbc2bbacbeb36277411c45f94ea1fddc0996",
414
+ "github_zip_sha256": "997e304f71c69bb22c045e29a76313ba2e0fa0227020cd16b2416aee0d64482d",
415
+ "tray_assets": {
416
+ "aarch64-apple-darwin": "6e07850c91915d5f2c73d6ae5d44c9ccf9ef149f0a63268a9773f1bf215d4d50",
417
+ "aarch64-unknown-linux-musl": "d12869b53457de872054ee0edd4822a4bb53325700ee4b58c30a9c777d58a3b9",
418
+ "x86_64-apple-darwin": "e84c651fa3b20fc99f9d9cd71378a526089292a4122e111bde014159de046b31",
419
+ "x86_64-pc-windows-msvc": "53e6811d5c0489a6f08ec29d65bdbd5a1375aef64fc30ad72bc7e26d38ffbb6d",
420
+ "x86_64-unknown-linux-musl": "d35d02bc18ba80413858d8b9943aa24f6424544c4b6e65770f1dce722d346547"
421
+ }
411
422
  }
412
423
  }
413
424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.20.9",
3
+ "version": "0.20.10",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.20.9"
7
+ version = "0.20.10"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -17,6 +17,9 @@ _SCRYPT_P = 1
17
17
  _PBKDF2_ITERATIONS = 200000
18
18
  _AEAD_ALGORITHM = "aes-256-gcm"
19
19
  _LEGACY_ALGORITHM = "sha256-xor-hmac"
20
+ _KDF_SCRYPT = "scrypt"
21
+ _KDF_PBKDF2 = "pbkdf2-hmac-sha256"
22
+ _SUPPORTED_KDFS = (_KDF_SCRYPT, _KDF_PBKDF2)
20
23
 
21
24
 
22
25
  def _now_iso():
@@ -47,13 +50,42 @@ def read_bundle_meta(data):
47
50
  return wrapper
48
51
 
49
52
 
50
- def _derive_keys(passphrase, salt):
51
- if not passphrase:
52
- raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
53
- if isinstance(passphrase, str):
54
- passphrase = passphrase.encode("utf-8")
55
- if hasattr(hashlib, "scrypt"):
56
- key_material = hashlib.scrypt(
53
+ def _available_kdf(kdf):
54
+ return kdf != _KDF_SCRYPT or hasattr(hashlib, "scrypt")
55
+
56
+
57
+ def _default_kdf():
58
+ """The strongest KDF this runtime can compute. Recorded in the bundle."""
59
+ return _KDF_SCRYPT if _available_kdf(_KDF_SCRYPT) else _KDF_PBKDF2
60
+
61
+
62
+ def _bundle_kdf_candidates(wrapper):
63
+ """KDFs to try when decoding, in order.
64
+
65
+ A recorded KDF is authoritative: the importing runtime's own capabilities
66
+ must not silently select a different derivation, or a portable bundle stops
67
+ decoding after a move between machines. Bundles written before the field was
68
+ honoured record nothing reliable, so those keep the compatibility behaviour
69
+ of trying every KDF this runtime can compute.
70
+ """
71
+ recorded = wrapper.get("kdf")
72
+ if recorded is None:
73
+ default = _default_kdf()
74
+ ordered = [default, *(kdf for kdf in _SUPPORTED_KDFS if kdf != default)]
75
+ return [kdf for kdf in ordered if _available_kdf(kdf)]
76
+ if not isinstance(recorded, str) or recorded not in _SUPPORTED_KDFS:
77
+ raise CdxError("Unsupported bundle key derivation function.")
78
+ return [recorded]
79
+
80
+
81
+ def _derive_key_material(passphrase, salt, kdf):
82
+ if kdf == _KDF_SCRYPT:
83
+ if not _available_kdf(_KDF_SCRYPT):
84
+ raise CdxError(
85
+ "This bundle records the scrypt key derivation function, which this Python runtime "
86
+ "does not provide. Decode it with a Python built against OpenSSL scrypt support."
87
+ )
88
+ return hashlib.scrypt(
57
89
  passphrase,
58
90
  salt=salt,
59
91
  n=_SCRYPT_N,
@@ -61,19 +93,28 @@ def _derive_keys(passphrase, salt):
61
93
  p=_SCRYPT_P,
62
94
  dklen=64,
63
95
  )
64
- else:
65
- key_material = hashlib.pbkdf2_hmac(
96
+ if kdf == _KDF_PBKDF2:
97
+ return hashlib.pbkdf2_hmac(
66
98
  "sha256",
67
99
  passphrase,
68
100
  salt,
69
101
  _PBKDF2_ITERATIONS,
70
102
  dklen=64,
71
103
  )
104
+ raise CdxError("Unsupported bundle key derivation function.")
105
+
106
+
107
+ def _derive_keys(passphrase, salt, kdf=None):
108
+ if not passphrase:
109
+ raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
110
+ if isinstance(passphrase, str):
111
+ passphrase = passphrase.encode("utf-8")
112
+ key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf())
72
113
  return key_material[:32], key_material[32:]
73
114
 
74
115
 
75
- def _derive_aead_key(passphrase, salt):
76
- enc_key, _mac_key = _derive_keys(passphrase, salt)
116
+ def _derive_aead_key(passphrase, salt, kdf=None):
117
+ enc_key, _mac_key = _derive_keys(passphrase, salt, kdf)
77
118
  return enc_key
78
119
 
79
120
 
@@ -110,11 +151,12 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
110
151
  if include_auth:
111
152
  salt = os.urandom(_SALT_BYTES)
112
153
  nonce = os.urandom(_AEAD_NONCE_BYTES)
113
- aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt))
154
+ kdf = _default_kdf()
155
+ aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt, kdf))
114
156
  ciphertext = aesgcm.encrypt(nonce, payload_bytes, None)
115
157
  wrapper.update({
116
158
  "encryption": _AEAD_ALGORITHM,
117
- "kdf": "scrypt" if hasattr(hashlib, "scrypt") else "pbkdf2-hmac-sha256",
159
+ "kdf": kdf,
118
160
  "salt": _b64_encode(salt),
119
161
  "nonce": _b64_encode(nonce),
120
162
  "payload": _b64_encode(ciphertext),
@@ -138,19 +180,33 @@ def decode_bundle(data, passphrase=None):
138
180
  nonce = _b64_decode(wrapper.get("nonce", ""))
139
181
  ciphertext = _b64_decode(payload_b64)
140
182
  algorithm = wrapper.get("encryption") or _LEGACY_ALGORITHM
183
+ kdf_candidates = _bundle_kdf_candidates(wrapper)
141
184
  if algorithm == _AEAD_ALGORITHM:
142
- aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt))
143
- try:
144
- payload_bytes = aesgcm.decrypt(nonce, ciphertext, None)
145
- except Exception as error:
146
- raise CdxError("Invalid bundle passphrase or corrupted bundle.") from error
185
+ aead = _load_aesgcm()
186
+ payload_bytes = None
187
+ failure = None
188
+ for kdf in kdf_candidates:
189
+ # Derivation errors are about the bundle, not the passphrase, so
190
+ # they must escape rather than be retried as a bad secret.
191
+ cipher = aead(_derive_aead_key(passphrase, salt, kdf))
192
+ try:
193
+ payload_bytes = cipher.decrypt(nonce, ciphertext, None)
194
+ break
195
+ except Exception as error: # a wrong KDF looks exactly like a wrong passphrase.
196
+ failure = error
197
+ if payload_bytes is None:
198
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.") from failure
147
199
  elif algorithm == _LEGACY_ALGORITHM:
148
200
  expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
149
- enc_key, mac_key = _derive_keys(passphrase, salt)
150
- actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
151
- if not hmac.compare_digest(actual_mac, expected_mac):
201
+ payload_bytes = None
202
+ for kdf in kdf_candidates:
203
+ enc_key, mac_key = _derive_keys(passphrase, salt, kdf)
204
+ actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
205
+ if hmac.compare_digest(actual_mac, expected_mac):
206
+ payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
207
+ break
208
+ if payload_bytes is None:
152
209
  raise CdxError("Invalid bundle passphrase or corrupted bundle.")
153
- payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
154
210
  else:
155
211
  raise CdxError("Unsupported bundle encryption algorithm.")
156
212
  else:
@@ -109,4 +109,9 @@ def handle_import(rest, ctx):
109
109
  _write_json(ctx, payload)
110
110
  return 0
111
111
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
112
+ retained = result.get("retained_local_credentials") or []
113
+ if retained:
114
+ # Merge keeps local values, so say which sessions kept their own login
115
+ # rather than leaving the operator to guess which credential is live.
116
+ ctx["out"](f"Kept the existing local Claude authentication for: {', '.join(retained)}\n")
112
117
  return 0
@@ -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):
@@ -1023,6 +1023,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1023
1023
 
1024
1024
  child = None
1025
1025
  timed_out = False
1026
+ cancelled = False
1026
1027
  with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
1027
1028
  open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
1028
1029
  options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
@@ -1056,6 +1057,25 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1056
1057
  except subprocess.TimeoutExpired:
1057
1058
  timed_out = True
1058
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
1059
1079
 
1060
1080
  _combine_headless_transcript(paths)
1061
1081
  end_time = datetime.now(timezone.utc)
@@ -12,7 +12,11 @@ import shutil
12
12
  import tempfile
13
13
 
14
14
  from .backup_bundle import decode_bundle, encode_bundle
15
- from .claude_credentials import delete_keychain_credentials, read_keychain_credentials
15
+ from .claude_credentials import (
16
+ delete_keychain_credentials,
17
+ read_keychain_credentials,
18
+ write_keychain_credentials,
19
+ )
16
20
  from .config import PROVIDER_CLAUDE
17
21
  from .errors import CdxError
18
22
  from .fs_utils import atomic_write, remove_tree
@@ -160,6 +164,19 @@ def _resolve_session_subset(store, session_names):
160
164
  selected.append(session)
161
165
  return selected
162
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
+
163
180
  def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
164
181
  if os.path.exists(session_root):
165
182
  remove_tree(session_root)
@@ -258,6 +275,7 @@ def import_bundle(base_dir, store, file_path,
258
275
  if missing_names:
259
276
  raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
260
277
  names = [item["name"] for item in imported_sessions]
278
+ retained_credential_names = []
261
279
 
262
280
  existing = {session["name"] for session in list_sessions(store)}
263
281
  conflicts = [name for name in names if name in existing]
@@ -313,6 +331,17 @@ def import_bundle(base_dir, store, file_path,
313
331
  remove_tree(recovery_dir, ignore_errors=True)
314
332
  raise
315
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)
344
+
316
345
  existing_state_before = None
317
346
  try:
318
347
  _ensure_private_dir(session_root)
@@ -366,6 +395,11 @@ def import_bundle(base_dir, store, file_path,
366
395
  # In merge mode, skip files that already exist locally.
367
396
  if is_existing and merge and os.path.lexists(dest_path):
368
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)
402
+ continue
369
403
  _ensure_private_dir(os.path.dirname(dest_path))
370
404
  # Decrypted credentials: 0o600 from creation, no umask window.
371
405
  atomic_write(dest_path, item["content"], mode=0o600)
@@ -374,10 +408,20 @@ def import_bundle(base_dir, store, file_path,
374
408
  delete_keychain_credentials(auth_home)
375
409
  _restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
376
410
  except Exception:
411
+ incomplete = []
377
412
  try:
378
413
  _restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
379
414
  except Exception:
380
- raise CdxError(f"Import failed and recovery is incomplete for {name}; recovery data retained at {recovery_dir or session_root}.") from None
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
381
425
  if recovery_dir:
382
426
  remove_tree(recovery_dir, ignore_errors=True)
383
427
  raise
@@ -389,4 +433,5 @@ def import_bundle(base_dir, store, file_path,
389
433
  "path": file_path,
390
434
  "session_names": names,
391
435
  "include_auth": bool(decoded["meta"].get("include_auth")),
436
+ "retained_local_credentials": retained_credential_names,
392
437
  }
@@ -226,7 +226,15 @@ def install(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, targe
226
226
  # Best-effort, and after the companion is known to be on disk: the shortcut
227
227
  # points at it. A failure here does not fail the install — the tray runs
228
228
  # either way, only its toasts would go nowhere, and doctor reports that.
229
- shortcut = create_shortcut(executable, env=env)
229
+ #
230
+ # Only the install that is about to be live writes it. A staged executable
231
+ # is going to move, and a shortcut recorded against the staged path is a
232
+ # broken Start Menu entry the moment it does; the promotion writes it
233
+ # against the final path instead.
234
+ shortcut = (
235
+ create_shortcut(executable, env=env) if record
236
+ else {"created": False, "path": None, "reason": "staged install; the promotion writes the shortcut"}
237
+ )
230
238
 
231
239
  state = {
232
240
  "version": STATE_VERSION,
@@ -292,9 +300,9 @@ def align_companion(
292
300
  try:
293
301
  result = update(
294
302
  base_dir, version,
295
- download=download, ledger_path=ledger_path, target=target, probe=probe,
303
+ download=download, ledger_path=ledger_path, target=target, probe=probe, env=env,
296
304
  )
297
- except TrayInstallError as error:
305
+ except (TrayInstallError, OSError) as error:
298
306
  outcome = {
299
307
  "aligned": False,
300
308
  "reason": str(error),
@@ -407,7 +415,8 @@ def staged_dir(base_dir):
407
415
  return os.path.join(state_dir(base_dir), "companion.staged")
408
416
 
409
417
 
410
- def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, probe=None):
418
+ def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, probe=None,
419
+ env=None):
411
420
  """Replace an installed companion without ever being left with none.
412
421
 
413
422
  The order is the whole point. The replacement is downloaded, verified and
@@ -444,18 +453,43 @@ def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target
444
453
  live = os.path.join(state_dir(base_dir), "companion")
445
454
  retired = previous_dir(base_dir)
446
455
  shutil.rmtree(retired, ignore_errors=True)
447
- if os.path.isdir(live):
448
- os.rename(live, retired)
449
- os.rename(staged, live)
456
+ retired_live = False
457
+ try:
458
+ if os.path.isdir(live):
459
+ os.rename(live, retired)
460
+ retired_live = True
461
+ os.rename(staged, live)
462
+ except OSError as error:
463
+ # A failure between the two renames used to leave the recorded
464
+ # executable pointing at nothing: the install claimed a live companion
465
+ # and the path was empty. Put the proven one back, and report as a
466
+ # TrayInstallError so the caller's restart path runs.
467
+ restored = True
468
+ if retired_live and not os.path.isdir(live):
469
+ try:
470
+ os.rename(retired, live)
471
+ except OSError:
472
+ restored = False
473
+ shutil.rmtree(staged, ignore_errors=True)
474
+ raise TrayInstallError(
475
+ f"Could not promote the staged tray companion for CDX {version}: {error}. "
476
+ + ("The installed one was restored." if restored
477
+ else f"The previous companion is retained at {retired}.")
478
+ ) from error
450
479
  # The retired copy stays. It used to be deleted here, which left nothing to
451
480
  # go back to the moment the replacement turned out not to start — and that
452
481
  # is precisely when it is needed. The next update clears it, so at most one
453
482
  # extra copy is ever on disk.
454
483
 
484
+ live_executable = fresh["executable"].replace(staged, live, 1)
485
+ # Written now, against the path that will still exist afterwards, and
486
+ # recorded so uninstall keeps owning it.
487
+ shortcut = create_shortcut(live_executable, env=env)
455
488
  state = {
456
489
  **fresh,
457
- "executable": fresh["executable"].replace(staged, live, 1),
458
- "paths": [live],
490
+ "executable": live_executable,
491
+ "paths": [live] + ([shortcut["path"]] if shortcut["created"] else []),
492
+ "shortcut": shortcut["path"] if shortcut["created"] else None,
459
493
  }
460
494
  _record_state(base_dir, state)
461
495
  return {"state": state, "replaced": previous.get("cdx_version"), "previous_state": previous}
@@ -496,17 +530,55 @@ def restore_previous(base_dir, previous_state):
496
530
  return False
497
531
 
498
532
 
533
+ # `--print` exits non-zero when CDX itself is unavailable, and that is a
534
+ # diagnostic that ran, not a companion that failed to launch. These are the
535
+ # codes that mean the program never got to run: the exec conventions for "found
536
+ # but not executable" and "not found". Death by signal is reported as a
537
+ # negative returncode and counts the same way.
538
+ _PROBE_LAUNCH_FAILURE_CODES = (126, 127)
539
+
540
+
541
+ def probe_command(executable):
542
+ """How to ask the companion itself for its diagnostic output.
543
+
544
+ Not `launch_command`: on macOS that hands the bundle to `open`, which takes
545
+ `--print` for itself and reports nothing whatsoever about the companion. The
546
+ binary inside the bundle is what has to run, and an app bundle with no
547
+ runnable binary in it is exactly the case this gate exists to refuse.
548
+ """
549
+ if platform.system() == "Darwin" and executable.endswith(".app"):
550
+ macos_dir = os.path.join(executable, "Contents", "MacOS")
551
+ try:
552
+ entries = sorted(os.listdir(macos_dir))
553
+ except OSError:
554
+ return None
555
+ for entry in entries:
556
+ candidate = os.path.join(macos_dir, entry)
557
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
558
+ return [candidate]
559
+ return None
560
+ return [executable]
561
+
562
+
499
563
  def _probe(executable):
500
- """Does this companion start at all? `--print` exits non-zero when CDX is
501
- unavailable, which is not the question here, so any completed run counts."""
564
+ """Does this companion actually run?
565
+
566
+ `--print` makes it emit its snapshot and exit, so a bundle that cannot
567
+ launch — wrong architecture, missing library, no binary inside it — is
568
+ refused here rather than after the working companion is gone.
569
+ """
570
+ command = probe_command(executable)
571
+ if not command:
572
+ return False
502
573
  try:
503
- subprocess.run( # noqa: S603 (path comes from our own install state)
504
- launch_command(executable) + ["--print"],
574
+ completed = subprocess.run( # noqa: S603 (path comes from our own install state)
575
+ command + ["--print"],
505
576
  capture_output=True, timeout=20, check=False,
506
577
  )
507
578
  except (OSError, subprocess.SubprocessError):
508
579
  return False
509
- return True
580
+ returncode = completed.returncode
581
+ return not (returncode < 0 or returncode in _PROBE_LAUNCH_FAILURE_CODES)
510
582
 
511
583
 
512
584
  def interrupted_update(base_dir):