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 +3 -3
- package/changelogs/CHANGELOGS_0_6_5.md +35 -0
- package/checksums/release-archives.json +4 -0
- package/install.sh +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/backup_bundle.py +40 -11
- package/src/claude_refresh.py +1 -0
- package/src/cli.py +1 -1
- package/src/cli_commands.py +23 -24
- package/src/provider_runtime.py +19 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
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
|
|
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.
|
|
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 -
|
|
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
|
-
|
|
51
|
+
' "$1"
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if [ -z "$VERSION" ]; then
|
package/package.json
CHANGED
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.
|
|
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"
|
package/src/backup_bundle.py
CHANGED
|
@@ -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(
|
|
94
|
-
|
|
95
|
-
ciphertext =
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
package/src/claude_refresh.py
CHANGED
|
@@ -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
package/src/cli_commands.py
CHANGED
|
@@ -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
|
-
|
|
896
|
-
|
|
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:
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
-
|
|
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"])
|