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 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.8-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
 
@@ -128,6 +128,7 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
128
128
  - Environment isolation per session:
129
129
  - Codex sessions override `CODEX_HOME` to a dedicated profile directory.
130
130
  - Claude sessions override `HOME` to a dedicated profile directory and disable Claude Code commit co-author attribution by default.
131
+ - On macOS, Claude credentials use a profile-specific keychain entry. Quota refresh reads that entry before legacy credential files; `cdx cp` and `cdx ren` transfer only the profile's entry, never the system keychain directory. Rename refuses existing destination entries and reports any old-entry cleanup failure after the new session becomes usable; `cdx rm` deletes the profile's entry with the profile.
131
132
  - Antigravity sessions launch `agy` with a dedicated `HOME` for file-based settings; Google account credentials may still be stored in the OS keyring by Antigravity itself.
132
133
  - Ollama sessions use the local Ollama server and launch `ollama run <model>`; set a model with `cdx set <name> --model <model>`.
133
134
  - New Codex sessions seed their auth home from your existing global `~/.codex/auth.json` when available, so an already logged-in Codex CLI can be reused without giving up per-session isolation afterward.
@@ -910,6 +911,8 @@ cdx import backup-auth.cdx --passphrase-env CDX_BUNDLE_PASSPHRASE
910
911
  Notes:
911
912
 
912
913
  - `--include-auth` is encrypted, requires a passphrase, and exports only provider credential files rather than full profile caches, logs, or installed plugins.
914
+ - Keychain-backed Claude authentication cannot currently be exported. If any selected profile uses it, `--include-auth` exits non-zero before writing anything, including with `--force`; text and JSON errors identify the affected profiles. Export without `--include-auth` and log in after import, or select only profiles whose authentication is file-backed. An inaccessible keychain is an explicit error, not a successful incomplete export.
915
+ - Merge imports snapshot each existing profile before modification, preserving symlinks instead of following them. This temporarily needs space for a second copy of that profile. On failure, CDX restores the original files, record and state; if recovery also fails, the error gives the retained recovery directory containing the original metadata and any remaining profile snapshot.
913
916
  - Without `--passphrase-env`, `cdx` prompts in an interactive terminal. Non-interactive callers can pass `--passphrase-stdin` to read the passphrase from stdin (one line) instead of exposing it in the child environment.
914
917
  - `--sessions work,perso` exports or imports only a subset.
915
918
  - `--force` allows overwriting existing destination sessions during import or replacing an existing bundle file during export. Import preserves detected local `plugins/` state when the bundle does not provide it, and refuses to overwrite existing sessions from a bundle without auth payloads unless `--allow-authless-force` is also passed.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.20.8
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.
@@ -0,0 +1,39 @@
1
+ # CDX Manager 0.20.9
2
+
3
+ ## Fixes
4
+
5
+ - Preserve an existing session when `cdx import --merge` fails. The profile,
6
+ its record and its state are snapshotted first — symlinks kept as links —
7
+ and restored on failure; if recovery itself fails, the error names the
8
+ retained recovery directory instead of leaving the session destroyed.
9
+ Bundle paths that escape the profile through a link are refused.
10
+ - Stop `cdx cp` from copying the profile's macOS keychain directory. Only the
11
+ source profile's own keychain entry is transferred, and an overwritten
12
+ destination entry is restored if the copy fails.
13
+ - Keep Claude authentication working across `cdx ren`. The destination
14
+ keychain entry is staged before the profile moves, an existing destination
15
+ entry is refused, and a failed cleanup of the old entry is reported once the
16
+ renamed session is already usable.
17
+ - Delete a session's Claude keychain credential when the session is removed,
18
+ so the OAuth token does not outlive the profile and the freed name can be
19
+ reused by `cdx cp` and `cdx ren`.
20
+ - Refresh Claude quota from the profile-scoped keychain entry before falling
21
+ back to legacy credential files, so keychain-authenticated sessions no
22
+ longer read as missing credentials. An unreadable keychain is an explicit
23
+ error, never a silent "not logged in" — unless a credential file on disk
24
+ can still answer, in which case `cdx` uses it and says nothing.
25
+
26
+ - Export keychain-backed Claude authentication. `cdx export --include-auth`
27
+ carries the profile's keychain entry in the encrypted bundle as its
28
+ credential file, and `cdx import` restores a usable login, replacing any
29
+ entry the destination profile already had so the imported credential is the
30
+ one in effect. A keychain that cannot be read is an explicit error before
31
+ anything is written, in both text and JSON, so an existing `--force`
32
+ destination survives it.
33
+
34
+ ## Notes
35
+
36
+ - A credential restored from a bundle lands in the profile's credential file
37
+ rather than the keychain; Claude Code moves it back on its next launch.
38
+ - Merge imports temporarily need room for a second copy of the profile being
39
+ merged into.
@@ -397,6 +397,28 @@
397
397
  "x86_64-pc-windows-msvc": "532fcdabf7ff249eafaea6518668e7a175ae8763de1f23c5abc9bb2c5d4e5eac",
398
398
  "x86_64-unknown-linux-musl": "224277ab55219bb5d94386f57fa00bc043537ad97ecf04fd68afd2a7986caa87"
399
399
  }
400
+ },
401
+ "v0.20.8": {
402
+ "github_tarball_sha256": "388f32a28e1ae816679947d3422a43d413c665f6b7eb445c9c92e177d7bb2cfa",
403
+ "github_zip_sha256": "82bfea6857a2e365e4fab7a7996a301a725c4fc98d7d83acfbb676a6aa95ae8e",
404
+ "tray_assets": {
405
+ "aarch64-apple-darwin": "38fc94180e73a440cba7dee576271a9189f1050d297456d0d4eebd4314f794b2",
406
+ "aarch64-unknown-linux-musl": "6713672f6a5effc544d056ae1aacc8929e9c6c63e1f44b7eff50c64fb0395810",
407
+ "x86_64-apple-darwin": "69611a302855f38fbc79b650004af3aad8f03c7aca65d91d5820bf49fb499edd",
408
+ "x86_64-pc-windows-msvc": "343245a6ce52275e8e7bd9a380d9e03fd1db3bcf917df3e6cecde2d0be1bfaeb",
409
+ "x86_64-unknown-linux-musl": "5d6e776b5ce64a062c798c5c00f9e85e003b5b6888cece74173bff1f45247f34"
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
+ }
400
422
  }
401
423
  }
402
424
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.20.8",
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.8"
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:
@@ -0,0 +1,148 @@
1
+ """Profile-scoped macOS credential access, matching Claude Code 2.1.263."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import unicodedata
10
+ from contextlib import contextmanager
11
+
12
+ from .errors import CdxError
13
+
14
+ KEYCHAIN_TIMEOUT_SECONDS = 5
15
+
16
+
17
+ def _keychain_identity(auth_home):
18
+ if not auth_home or not os.path.isabs(auth_home):
19
+ raise CdxError("Claude keychain access requires an absolute profile home.")
20
+ user = os.environ.get("USER")
21
+ if not user:
22
+ import pwd
23
+ user = pwd.getpwuid(os.getuid()).pw_name
24
+ if not re.fullmatch(r"[a-zA-Z0-9._-]+", user):
25
+ user = "claude-code-user"
26
+ config_dir = unicodedata.normalize("NFC", os.path.join(auth_home, ".claude"))
27
+ suffix = hashlib.sha256(config_dir.encode("utf-8")).hexdigest()[:8]
28
+ return user, f"Claude Code-credentials-{suffix}"
29
+
30
+
31
+ def _reject_custom_oauth():
32
+ """Only writes are guarded: a custom endpoint stores credentials elsewhere,
33
+ so reading the standard entry is harmless but changing it is not."""
34
+ if os.environ.get("USE_LOCAL_OAUTH") or os.environ.get("CLAUDE_CODE_CUSTOM_OAUTH_URL"):
35
+ raise CdxError("CDX keychain operations do not support custom Claude OAuth endpoints.")
36
+
37
+
38
+ def _security(args, *, input_text=None):
39
+ import pwd
40
+
41
+ # HOME may already point at a profile. Always resolve the operator's actual keychain.
42
+ try:
43
+ env = {**os.environ, "HOME": pwd.getpwuid(os.getuid()).pw_dir}
44
+ result = subprocess.run(["/usr/bin/security", *args], input=input_text,
45
+ capture_output=True, text=True, timeout=KEYCHAIN_TIMEOUT_SECONDS,
46
+ env=env, check=False)
47
+ except subprocess.TimeoutExpired:
48
+ raise CdxError("Claude keychain operation timed out; authentication was not verified.") from None
49
+ except (OSError, KeyError, UnicodeError):
50
+ raise CdxError("Claude keychain is unavailable; authentication was not verified.") from None
51
+ return result
52
+
53
+
54
+ def read_keychain_credentials(auth_home):
55
+ if sys.platform != "darwin" or not auth_home:
56
+ return None
57
+ user, service = _keychain_identity(auth_home)
58
+ result = _security(["find-generic-password", "-a", user, "-s", service, "-w"])
59
+ if result.returncode == 44: # errSecItemNotFound, not a locked or missing keychain.
60
+ return None
61
+ if result.returncode != 0:
62
+ raise CdxError("Claude keychain access failed; unlock or allow access to the profile credential.")
63
+ try:
64
+ data = json.loads(result.stdout)
65
+ except (ValueError, TypeError):
66
+ raise CdxError("Claude keychain credential data is malformed.") from None
67
+ if not isinstance(data, dict):
68
+ raise CdxError("Claude keychain credential data is malformed.")
69
+ return data
70
+
71
+
72
+ def peek_keychain_credentials(auth_home):
73
+ """The profile entry, or None when the keychain cannot answer.
74
+
75
+ For callers with a file-based fallback. Callers that would otherwise
76
+ report a locked keychain as a logged-out profile use the strict read.
77
+ """
78
+ try:
79
+ return read_keychain_credentials(auth_home)
80
+ except CdxError:
81
+ return None
82
+
83
+
84
+ def write_keychain_credentials(auth_home, data):
85
+ _reject_custom_oauth()
86
+ user, service = _keychain_identity(auth_home)
87
+ encoded = json.dumps(data, ensure_ascii=True, separators=(",", ":")).encode("utf-8").hex()
88
+ command = f'add-generic-password -U -a "{user}" -s "{service}" -X "{encoded}"\n'
89
+ # ponytail: security -i has a 4096-byte line limit; use native Security APIs if larger entries are needed.
90
+ if len(command) > 4000:
91
+ raise CdxError(
92
+ "Claude profile credential exceeds the safe keychain transfer size; nothing was changed. "
93
+ "Create the session and log in inside it instead of transferring the credential."
94
+ )
95
+ # Credentials travel through stdin, never argv, shell text or error diagnostics.
96
+ result = _security(["-i"], input_text=command)
97
+ if result.returncode != 0:
98
+ raise CdxError("Could not write the destination Claude keychain credential.")
99
+ # security's interactive driver may exit successfully after a failed command.
100
+ if read_keychain_credentials(auth_home) != data:
101
+ raise CdxError("Destination Claude keychain credential verification failed.")
102
+
103
+
104
+ def delete_keychain_credentials(auth_home):
105
+ # Same guard as the read: off macOS there is no keychain to clear, and
106
+ # `cdx rm`/`cdx import` must not fail on a missing `security` binary.
107
+ if sys.platform != "darwin" or not auth_home:
108
+ return
109
+ _reject_custom_oauth()
110
+ user, service = _keychain_identity(auth_home)
111
+ result = _security(["delete-generic-password", "-a", user, "-s", service])
112
+ if result.returncode not in (0, 44):
113
+ raise CdxError("Could not remove the profile's Claude keychain credential.")
114
+
115
+
116
+ @contextmanager
117
+ def copy_keychain_credentials(source_home, dest_home, *, overwrite=False):
118
+ """Stage a profile credential and restore the destination if its operation fails."""
119
+ if sys.platform != "darwin":
120
+ yield False
121
+ return
122
+ if _keychain_identity(source_home) == _keychain_identity(dest_home):
123
+ raise CdxError("Source and destination Claude keychain identities collide.")
124
+ source = read_keychain_credentials(source_home)
125
+ previous = read_keychain_credentials(dest_home)
126
+ if previous is not None and not overwrite:
127
+ raise CdxError(
128
+ "Destination already has a Claude keychain credential; refusing to overwrite it. "
129
+ f"If no session owns it, clear it with: security delete-generic-password -s '{_keychain_identity(dest_home)[1]}'"
130
+ )
131
+ changed = source != previous
132
+ try:
133
+ if changed:
134
+ if source is None:
135
+ delete_keychain_credentials(dest_home)
136
+ else:
137
+ write_keychain_credentials(dest_home, source)
138
+ yield source is not None
139
+ except Exception:
140
+ if changed:
141
+ try:
142
+ if previous is None:
143
+ delete_keychain_credentials(dest_home)
144
+ else:
145
+ write_keychain_credentials(dest_home, previous)
146
+ except CdxError:
147
+ raise CdxError("Profile operation failed; destination keychain recovery is incomplete. Source credentials were retained.") from None
148
+ raise
@@ -8,6 +8,12 @@ import urllib.error
8
8
  import urllib.request
9
9
  from datetime import datetime, timezone
10
10
 
11
+ try: # POSIX only; the keychain wiring below is macOS-specific anyway.
12
+ import pwd
13
+ except ImportError: # pragma: no cover - Windows
14
+ pwd = None
15
+
16
+ from .claude_credentials import read_keychain_credentials
11
17
  from .errors import CdxError
12
18
 
13
19
  MONTH_ABBR = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
@@ -45,6 +51,20 @@ def _decode_jwt_claims(token):
45
51
 
46
52
 
47
53
  def _read_claude_credentials(auth_home):
54
+ # An unusable keychain is only an error when no credential file answers
55
+ # either: a locked keychain must not read as a logged-out profile, and
56
+ # must not hide a credential that is sitting on disk.
57
+ keychain_error = None
58
+ try:
59
+ keychain = read_keychain_credentials(auth_home)
60
+ except CdxError as error:
61
+ keychain, keychain_error = None, error
62
+ if keychain is not None:
63
+ creds = keychain.get("claudeAiOauth")
64
+ token = _clean_oauth_token(creds.get("accessToken")) if isinstance(creds, dict) else None
65
+ if token:
66
+ return {**creds, "accessToken": token}
67
+ keychain_error = CdxError("Claude keychain OAuth credential is malformed.")
48
68
  cred_path = os.path.join(auth_home, ".claude", ".credentials.json")
49
69
  try:
50
70
  with open(cred_path, encoding="utf-8") as f:
@@ -65,9 +85,66 @@ def _read_claude_credentials(auth_home):
65
85
  return {"accessToken": token}
66
86
  except (FileNotFoundError, json.JSONDecodeError, OSError):
67
87
  pass
88
+
89
+ if keychain_error is not None:
90
+ raise keychain_error
68
91
  return None
69
92
 
70
93
 
94
+ def _link_macos_keychain(auth_home):
95
+ """Make the operator's login keychain reachable from a redirected home.
96
+
97
+ macOS resolves the login keychain through `$HOME/Library/Keychains`.
98
+ Profile isolation redirects HOME (see `_home_env_overrides`), so that path
99
+ does not exist: every Claude Code launch then raises a "Keychain Not Found"
100
+ dialog before falling back to a plaintext credentials file. Linking the
101
+ real keychain directory into the profile home restores the lookup. Failure
102
+ is non-fatal, and deliberately quiet: the profile simply keeps the
103
+ file-based credentials it already used.
104
+ """
105
+ if sys.platform != "darwin" or pwd is None:
106
+ return
107
+ try:
108
+ real_home = pwd.getpwuid(os.getuid()).pw_dir
109
+ except (KeyError, OSError):
110
+ return
111
+ source = os.path.join(real_home, "Library", "Keychains")
112
+ if not os.path.isdir(source):
113
+ return
114
+ link = os.path.join(auth_home, "Library", "Keychains")
115
+ if os.path.realpath(link) == os.path.realpath(source):
116
+ return
117
+ try:
118
+ os.makedirs(os.path.dirname(link), exist_ok=True)
119
+ if os.path.islink(link):
120
+ os.unlink(link)
121
+ os.symlink(source, link)
122
+ except OSError:
123
+ return
124
+
125
+
126
+ def _secure_storage_overrides(auth_home):
127
+ """Key Claude Code's credential storage to this profile.
128
+
129
+ Claude Code names its stored credential from
130
+ `CLAUDE_SECURESTORAGE_CONFIG_DIR`, falling back to `CLAUDE_CONFIG_DIR`,
131
+ which cdx clears to keep profile credentials isolated. With neither set,
132
+ every profile writes the same entry, so any store shared between profiles
133
+ would have them overwrite each other. Only macOS reads such a store today
134
+ (the login keychain, reached through `/usr/bin/security`); Windows
135
+ Credential Manager sits behind an opt-in flag and Linux has no keyring
136
+ backend, so elsewhere this is inert but keeps profiles isolated should that
137
+ change.
138
+
139
+ The value must be the config dir Claude Code would have resolved on its own
140
+ -- `<home>/.claude`, since cdx leaves `CLAUDE_CONFIG_DIR` unset -- because
141
+ the variable overrides that directory as well as naming the entry. Any
142
+ other value would also move where credentials are read from.
143
+ """
144
+ _link_macos_keychain(auth_home)
145
+ return {"CLAUDE_SECURESTORAGE_CONFIG_DIR": os.path.join(auth_home, ".claude")}
146
+
147
+
71
148
  def _home_env_overrides(auth_home):
72
149
  overrides = {
73
150
  "HOME": auth_home,
@@ -78,6 +155,7 @@ def _home_env_overrides(auth_home):
78
155
  overrides["USERPROFILE"] = auth_home
79
156
  overrides["HOMEDRIVE"] = os.path.splitdrive(auth_home)[0] or "C:"
80
157
  overrides["HOMEPATH"] = os.path.splitdrive(auth_home)[1] or auth_home
158
+ overrides.update(_secure_storage_overrides(auth_home))
81
159
  return overrides
82
160
 
83
161
 
@@ -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