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 +4 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_10.md +71 -0
- package/changelogs/CHANGELOGS_0_20_9.md +39 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +78 -22
- package/src/claude_credentials.py +148 -0
- package/src/claude_usage.py +78 -0
- package/src/commands/backup.py +5 -0
- package/src/commands/runs.py +22 -5
- package/src/context_store.py +22 -3
- package/src/provider_runtime.py +30 -3
- package/src/session_backup.py +117 -23
- package/src/session_service.py +76 -34
- package/src/tray_install.py +86 -14
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
package/src/backup_bundle.py
CHANGED
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
package/src/claude_usage.py
CHANGED
|
@@ -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
|
|
package/src/commands/backup.py
CHANGED
|
@@ -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
|