cdx-manager 0.6.4 → 0.6.5

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
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.4-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.6.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -51,7 +51,7 @@ One command to launch any session. Zero auth juggling.
51
51
 
52
52
  ## Technical Overview
53
53
 
54
- - Python 3.9+, zero runtime dependencies.
54
+ - Python 3.9+.
55
55
  - Environment isolation per session:
56
56
  - Codex sessions override `CODEX_HOME` to a dedicated profile directory.
57
57
  - Claude sessions override `HOME` to a dedicated profile directory and disable Claude Code commit co-author attribution by default.
@@ -134,7 +134,7 @@ For a specific version:
134
134
 
135
135
  ```bash
136
136
  curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
137
- CDX_VERSION=v0.6.4 sh install.sh
137
+ CDX_VERSION=v0.6.5 sh install.sh
138
138
  ```
139
139
 
140
140
  From source:
@@ -0,0 +1,35 @@
1
+ # Changelog (`0.6.4 -> 0.6.5`)
2
+
3
+ Release date: 2026-05-29
4
+
5
+ ## Installer Integrity
6
+
7
+ - Fixed standalone installer checksum resolution so the Unix installer can validate published GitHub archive checksums correctly.
8
+ - Added regression coverage for the release checksum lookup path used by the standalone installer.
9
+
10
+ ## Claude Authentication
11
+
12
+ - Preserved the captured Claude setup-token transcript when token extraction fails, making failed login setup recoverable and easier to inspect.
13
+ - Reordered Claude status handling so authentication is checked before refreshing usage, avoiding noisy usage refresh failures when Claude credentials are absent or invalid.
14
+
15
+ ## Codex Authentication
16
+
17
+ - Hardened Codex auth detection so an empty or unusable `auth.json` is no longer treated as an authenticated session.
18
+ - Added coverage for usable-token validation instead of relying on file presence alone.
19
+
20
+ ## Auth Bundle Security
21
+
22
+ - Replaced the previous homegrown auth bundle encryption with AEAD encryption via `cryptography`.
23
+ - Added wrong-passphrase and auth bundle regression tests for the new encrypted bundle format.
24
+ - Updated CI and publish workflows to install Python package dependencies before running Python-backed lint, tests, and version validation.
25
+
26
+ ## Release Metadata and Documentation
27
+
28
+ - Updated package metadata, CLI version output, README badge, pinned installer example, and release changelog to `v0.6.5`.
29
+
30
+ ## Validation and Regression Evidence
31
+
32
+ - `npm run lint`
33
+ - `npm test`
34
+ - `python -m build --sdist --wheel`
35
+ - `npm pack --dry-run`
@@ -20,6 +20,10 @@
20
20
  "v0.6.3": {
21
21
  "github_tarball_sha256": "539bc299fe2211cddcbe58db71a859ebbd1cb76aec254f6feef501fc038d7c62",
22
22
  "github_zip_sha256": "138c7c67ea1b512d71ed745559f8eed395accb9a06b3ed9d82c3ccc454aba12b"
23
+ },
24
+ "v0.6.4": {
25
+ "github_tarball_sha256": "f7c83dd1ae7506cf1ae6420e718a285f4dc96a10fb3a19e24cc17e79d75a46b4",
26
+ "github_zip_sha256": "d602800cf6b54f1e0adea751cc6b686ab2fc4a224715ade94f761df2ff7da2af"
23
27
  }
24
28
  }
25
29
  }
package/install.sh CHANGED
@@ -34,7 +34,7 @@ sha256_file() {
34
34
 
35
35
  resolve_expected_sha256() {
36
36
  curl -fsSL "$CHECKSUMS_URL" |
37
- python3 - "$1" <<'PY'
37
+ python3 -c '
38
38
  import json
39
39
  import sys
40
40
 
@@ -48,7 +48,7 @@ release = (payload.get("releases") or {}).get(tag) or {}
48
48
  value = release.get("github_tarball_sha256")
49
49
  if value:
50
50
  print(value)
51
- PY
51
+ ' "$1"
52
52
  }
53
53
 
54
54
  if [ -z "$VERSION" ]; then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
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.6.4"
7
+ version = "0.6.5"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Software Development",
27
27
  "Topic :: Terminals",
28
28
  ]
29
- dependencies = []
29
+ dependencies = ["cryptography>=42"]
30
30
 
31
31
  [project.urls]
32
32
  Homepage = "https://github.com/AlexAgo83/cdx-manager"
@@ -11,10 +11,13 @@ from .errors import CdxError
11
11
  BUNDLE_SCHEMA_VERSION = 1
12
12
  _SALT_BYTES = 16
13
13
  _NONCE_BYTES = 16
14
+ _AEAD_NONCE_BYTES = 12
14
15
  _SCRYPT_N = 2 ** 14
15
16
  _SCRYPT_R = 8
16
17
  _SCRYPT_P = 1
17
18
  _PBKDF2_ITERATIONS = 200000
19
+ _AEAD_ALGORITHM = "aes-256-gcm"
20
+ _LEGACY_ALGORITHM = "sha256-xor-hmac"
18
21
 
19
22
 
20
23
  def _now_iso():
@@ -68,6 +71,22 @@ def _derive_keys(passphrase, salt):
68
71
  return key_material[:32], key_material[32:]
69
72
 
70
73
 
74
+ def _derive_aead_key(passphrase, salt):
75
+ enc_key, _mac_key = _derive_keys(passphrase, salt)
76
+ return enc_key
77
+
78
+
79
+ def _load_aesgcm():
80
+ try:
81
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
82
+ except ImportError as error:
83
+ raise CdxError(
84
+ "Auth bundle encryption requires the Python package 'cryptography'. "
85
+ "Install cdx-manager through pipx/uv/pip, or install cryptography for the Python used by cdx."
86
+ ) from error
87
+ return AESGCM
88
+
89
+
71
90
  def _xor_keystream(data, key, nonce):
72
91
  output = bytearray()
73
92
  counter = 0
@@ -90,14 +109,14 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
90
109
  }
91
110
  if include_auth:
92
111
  salt = os.urandom(_SALT_BYTES)
93
- nonce = os.urandom(_NONCE_BYTES)
94
- enc_key, mac_key = _derive_keys(passphrase, salt)
95
- ciphertext = _xor_keystream(payload_bytes, enc_key, nonce)
96
- mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
112
+ nonce = os.urandom(_AEAD_NONCE_BYTES)
113
+ aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt))
114
+ ciphertext = aesgcm.encrypt(nonce, payload_bytes, None)
97
115
  wrapper.update({
116
+ "encryption": _AEAD_ALGORITHM,
117
+ "kdf": "scrypt" if hasattr(hashlib, "scrypt") else "pbkdf2-hmac-sha256",
98
118
  "salt": _b64_encode(salt),
99
119
  "nonce": _b64_encode(nonce),
100
- "hmac_sha256": _b64_encode(mac),
101
120
  "payload": _b64_encode(ciphertext),
102
121
  })
103
122
  else:
@@ -116,13 +135,23 @@ def decode_bundle(data, passphrase=None):
116
135
  if encrypted:
117
136
  salt = _b64_decode(wrapper.get("salt", ""))
118
137
  nonce = _b64_decode(wrapper.get("nonce", ""))
119
- expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
120
138
  ciphertext = _b64_decode(payload_b64)
121
- enc_key, mac_key = _derive_keys(passphrase, salt)
122
- actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
123
- if not hmac.compare_digest(actual_mac, expected_mac):
124
- raise CdxError("Invalid bundle passphrase or corrupted bundle.")
125
- payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
139
+ algorithm = wrapper.get("encryption") or _LEGACY_ALGORITHM
140
+ if algorithm == _AEAD_ALGORITHM:
141
+ aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt))
142
+ try:
143
+ payload_bytes = aesgcm.decrypt(nonce, ciphertext, None)
144
+ except Exception as error:
145
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.") from error
146
+ elif algorithm == _LEGACY_ALGORITHM:
147
+ expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
148
+ enc_key, mac_key = _derive_keys(passphrase, salt)
149
+ actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
150
+ if not hmac.compare_digest(actual_mac, expected_mac):
151
+ raise CdxError("Invalid bundle passphrase or corrupted bundle.")
152
+ payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
153
+ else:
154
+ raise CdxError("Unsupported bundle encryption algorithm.")
126
155
  else:
127
156
  payload_bytes = _b64_decode(payload_b64)
128
157
 
@@ -38,6 +38,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
38
38
  s for s in sessions
39
39
  if s["provider"] == PROVIDER_CLAUDE
40
40
  and s.get("enabled", True) is not False
41
+ and (s.get("auth") or {}).get("status") != "logged_out"
41
42
  and (not target_names or s["name"] in target_names)
42
43
  and (force or _is_stale(s, ttl_seconds=ttl_seconds))
43
44
  ]
package/src/cli.py CHANGED
@@ -55,7 +55,7 @@ from .status_view import (
55
55
  )
56
56
  from .update_check import check_for_update
57
57
 
58
- VERSION = "0.6.4"
58
+ VERSION = "0.6.5"
59
59
 
60
60
 
61
61
  # ---------------------------------------------------------------------------
@@ -892,19 +892,18 @@ def _bootstrap_claude_setup_token(session, ctx):
892
892
  "Claude setup-token completed, but cdx could not capture the token. "
893
893
  "Run claude setup-token and save the token under credentials/default.json."
894
894
  )
895
- try:
896
- with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
897
- transcript = handle.read()
898
- finally:
899
- try:
900
- os.remove(transcript_path)
901
- except OSError:
902
- pass
895
+ with open(transcript_path, "r", encoding="utf-8", errors="replace") as handle:
896
+ transcript = handle.read()
903
897
  token = _extract_claude_oauth_token(transcript)
904
898
  if not token:
905
899
  raise CdxError(
906
- "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output."
900
+ "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
901
+ f"Transcript kept at: {transcript_path}"
907
902
  )
903
+ try:
904
+ os.remove(transcript_path)
905
+ except OSError:
906
+ pass
908
907
  return _write_claude_oauth_token(session.get("authHome") or "", token)
909
908
 
910
909
 
@@ -1599,6 +1598,20 @@ def handle_status(rest, ctx):
1599
1598
  if len(args) == 1 and parsed["small"]:
1600
1599
  raise CdxError(STATUS_USAGE)
1601
1600
 
1601
+ auth_refresh = _refresh_claude_auth_states(
1602
+ ctx["service"],
1603
+ target_names=args if len(args) == 1 else None,
1604
+ spawn_sync=ctx.get("spawn_sync"),
1605
+ env_override=ctx.get("env"),
1606
+ )
1607
+ warnings = [
1608
+ {
1609
+ "code": "claude_auth_probe_failed",
1610
+ "session": item.get("session") or "unknown",
1611
+ "message": item.get("error") or "unknown error",
1612
+ }
1613
+ for item in auth_refresh.get("errors", [])
1614
+ ]
1602
1615
  refresh_result = _refresh_claude_sessions(
1603
1616
  ctx["service"],
1604
1617
  ctx.get("refresh_fn"),
@@ -1612,27 +1625,13 @@ def handle_status(rest, ctx):
1612
1625
  }
1613
1626
  for item in refresh_result.get("errors", [])
1614
1627
  ]
1615
- warnings = [
1628
+ warnings.extend([
1616
1629
  {
1617
1630
  "code": "claude_refresh_failed",
1618
1631
  "session": item.get("session") or "unknown",
1619
1632
  "message": item.get("error") or "unknown error",
1620
1633
  }
1621
1634
  for item in refresh_errors
1622
- ]
1623
- auth_refresh = _refresh_claude_auth_states(
1624
- ctx["service"],
1625
- target_names=args if len(args) == 1 else None,
1626
- spawn_sync=ctx.get("spawn_sync"),
1627
- env_override=ctx.get("env"),
1628
- )
1629
- warnings.extend([
1630
- {
1631
- "code": "claude_auth_probe_failed",
1632
- "session": item.get("session") or "unknown",
1633
- "message": item.get("error") or "unknown error",
1634
- }
1635
- for item in auth_refresh.get("errors", [])
1636
1635
  ])
1637
1636
  update_warning = _update_notice_warning(ctx)
1638
1637
  if update_warning:
@@ -96,6 +96,23 @@ def _has_local_claude_auth(auth_home):
96
96
  return bool(isinstance(oauth, dict) and _clean_oauth_token(oauth.get("accessToken")))
97
97
 
98
98
 
99
+ def _has_local_codex_auth(auth_home):
100
+ try:
101
+ with open(os.path.join(auth_home, "auth.json"), "r", encoding="utf-8") as handle:
102
+ auth = json.load(handle)
103
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
104
+ return False
105
+ if not isinstance(auth, dict):
106
+ return False
107
+ tokens = auth.get("tokens")
108
+ if not isinstance(tokens, dict):
109
+ return False
110
+ return any(
111
+ _clean_oauth_token(tokens.get(name))
112
+ for name in ("id_token", "access_token", "refresh_token")
113
+ )
114
+
115
+
99
116
  def _read_claude_account_email(auth_home):
100
117
  config_path = os.path.join(auth_home, ".claude.json")
101
118
  try:
@@ -397,10 +414,8 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None):
397
414
  spec = _build_login_status_spec(session, env_override)
398
415
  if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
399
416
  return True
400
- if session.get("provider") == PROVIDER_CODEX:
401
- auth_path = os.path.join(_get_auth_home(session), "auth.json")
402
- if os.path.isfile(auth_path):
403
- return True
417
+ if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
418
+ return True
404
419
  try:
405
420
  if spawn_sync is subprocess.run:
406
421
  command = _resolve_command(spec["command"], spec["env"])