cdx-manager 0.20.8 → 0.20.9

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.9-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.9
@@ -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,17 @@
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
+ }
400
411
  }
401
412
  }
402
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.20.8",
3
+ "version": "0.20.9",
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.9"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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
 
@@ -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):
@@ -6,11 +6,13 @@ 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 delete_keychain_credentials, read_keychain_credentials
14
16
  from .config import PROVIDER_CLAUDE
15
17
  from .errors import CdxError
16
18
  from .fs_utils import atomic_write, remove_tree
@@ -55,12 +57,14 @@ def _build_export_session_record(session):
55
57
  "auth": session.get("auth"),
56
58
  }
57
59
 
60
+ CLAUDE_CREDENTIALS_BUNDLE_PATH = "claude-home/.claude/.credentials.json"
61
+
58
62
  def _auth_bundle_paths(provider):
59
63
  if provider == PROVIDER_CLAUDE:
60
64
  return [
61
65
  "claude-home/configs/default.json",
62
66
  "claude-home/credentials/default.json",
63
- "claude-home/.claude/.credentials.json",
67
+ CLAUDE_CREDENTIALS_BUNDLE_PATH,
64
68
  "claude-home/.claude.json",
65
69
  "claude-home/auth.json",
66
70
  ]
@@ -68,19 +72,26 @@ def _auth_bundle_paths(provider):
68
72
  "auth.json",
69
73
  ]
70
74
 
71
- def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None):
75
+ def _collect_auth_files(session_root, provider, session_name=None, progress_callback=None, auth_home=None):
72
76
  files = []
73
77
  total_bytes = 0
74
78
  if not os.path.isdir(session_root):
75
79
  return {"files": files, "file_count": 0, "bytes": 0}
80
+ # A keychain-backed profile has no credential file to read. Export the entry
81
+ # under the path Claude Code itself uses, which is the same document: the
82
+ # bundle stays one schema, and an import restores a usable login.
83
+ keychain = read_keychain_credentials(auth_home) if provider == PROVIDER_CLAUDE and auth_home else None
76
84
  for rel_path in _auth_bundle_paths(provider):
77
85
  full_path = os.path.join(session_root, rel_path)
78
- if not os.path.isfile(full_path):
86
+ if keychain is not None and rel_path == CLAUDE_CREDENTIALS_BUNDLE_PATH:
87
+ raw_content = json.dumps(keychain).encode("utf-8")
88
+ elif os.path.isfile(full_path):
89
+ with open(full_path, "rb") as handle:
90
+ raw_content = handle.read()
91
+ else:
79
92
  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")
93
+ total_bytes += len(raw_content)
94
+ content = base64.b64encode(raw_content).decode("ascii")
84
95
  files.append({"path": rel_path.replace(os.sep, "/"), "data_b64": content})
85
96
  if progress_callback:
86
97
  progress_callback({
@@ -151,7 +162,7 @@ def _resolve_session_subset(store, session_names):
151
162
 
152
163
  def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
153
164
  if os.path.exists(session_root):
154
- remove_tree(session_root, ignore_errors=True)
165
+ remove_tree(session_root)
155
166
  if backup_root and os.path.exists(backup_root):
156
167
  os.rename(backup_root, session_root)
157
168
  if old_record:
@@ -194,7 +205,12 @@ def export_bundle(base_dir, store, file_path, include_auth=False, session_names=
194
205
  payload["states"][session["name"]] = state
195
206
  if include_auth:
196
207
  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)
208
+ auth_home = session.get("authHome") or _get_session_auth_home(base_dir, session["name"], session["provider"])
209
+ try:
210
+ profile = _collect_auth_files(session_root, session["provider"], session["name"], progress_callback,
211
+ auth_home=auth_home)
212
+ except CdxError as error:
213
+ raise CdxError(f"Cannot export authentication for {session['name']}: {error}. Nothing was exported.") from None
198
214
  payload["profiles"][session["name"]] = profile["files"]
199
215
  profile_file_count += profile["file_count"]
200
216
  profile_bytes += profile["bytes"]
@@ -268,23 +284,39 @@ def import_bundle(base_dir, store, file_path,
268
284
  _ensure_private_dir(base_dir)
269
285
  _ensure_private_dir(os.path.join(base_dir, "profiles"))
270
286
  backup_root = None
287
+ recovery_dir = None
271
288
  old_record = None
272
289
  old_state = None
273
290
  preserved_profile_paths = []
274
- if is_existing and force:
291
+ if is_existing and (force or merge):
275
292
  old_record = store["get_session"](name)
276
293
  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)
294
+ if merge:
295
+ provider = old_record["provider"]
296
+ auth_home = old_record["authHome"]
297
+ recovery_dir = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
298
+ backup_root = os.path.join(recovery_dir, "profile")
299
+ try:
300
+ atomic_write(os.path.join(recovery_dir, "recovery.json"),
301
+ json.dumps({"session": old_record, "state": old_state}), mode=0o600)
302
+ if force:
303
+ preserved_profile_paths = _force_import_preserved_profile_paths(session_root)
304
+ if os.path.exists(session_root):
305
+ os.rename(session_root, backup_root)
306
+ is_existing = False
307
+ elif os.path.exists(session_root):
308
+ # ponytail: full profile snapshot; journal new merge files if copy cost becomes material.
309
+ # Keep links as links: a Claude profile can reference the system keychain,
310
+ # and a dangling one (stale plugin or npm cache) must not fail the import.
311
+ shutil.copytree(session_root, backup_root, symlinks=True, ignore_dangling_symlinks=True)
312
+ except Exception:
313
+ remove_tree(recovery_dir, ignore_errors=True)
314
+ raise
285
315
 
286
316
  existing_state_before = None
287
317
  try:
318
+ _ensure_private_dir(session_root)
319
+ _ensure_private_dir(auth_home)
288
320
  if is_existing and merge:
289
321
  existing_record = store["get_session"](name) or {}
290
322
  # replace_session resets the state file to defaults, so the
@@ -320,21 +352,38 @@ def import_bundle(base_dir, store, file_path,
320
352
  elif state is not None:
321
353
  store["write_session_state"](name, state)
322
354
 
355
+ imported_paths = []
323
356
  for item in decoded_profiles.get(name, []):
324
357
  dest_path = os.path.join(session_root, item["path"])
358
+ # A syntactically relative path can still escape through a profile symlink.
359
+ root = os.path.realpath(session_root)
360
+ try:
361
+ inside = os.path.commonpath([root, os.path.realpath(dest_path)]) == root
362
+ except ValueError: # different drive: never inside the profile.
363
+ inside = False
364
+ if not inside:
365
+ raise CdxError("Bundle file path escapes the session profile through a link.")
325
366
  # In merge mode, skip files that already exist locally.
326
- if is_existing and merge and os.path.exists(dest_path):
367
+ if is_existing and merge and os.path.lexists(dest_path):
327
368
  continue
328
369
  _ensure_private_dir(os.path.dirname(dest_path))
329
370
  # Decrypted credentials: 0o600 from creation, no umask window.
330
371
  atomic_write(dest_path, item["content"], mode=0o600)
372
+ imported_paths.append(item["path"])
373
+ if provider == PROVIDER_CLAUDE and CLAUDE_CREDENTIALS_BUNDLE_PATH in imported_paths:
374
+ delete_keychain_credentials(auth_home)
331
375
  _restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
332
376
  except Exception:
333
- _restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
377
+ try:
378
+ _restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
379
+ 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
381
+ if recovery_dir:
382
+ remove_tree(recovery_dir, ignore_errors=True)
334
383
  raise
335
- finally:
336
- if backup_root and os.path.exists(backup_root):
337
- remove_tree(backup_root, ignore_errors=True)
384
+ else:
385
+ if recovery_dir:
386
+ remove_tree(recovery_dir, ignore_errors=True)
338
387
 
339
388
  return {
340
389
  "path": file_path,
@@ -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