cdx-manager 0.20.7 → 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 +4 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_8.md +13 -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/claude_credentials.py +148 -0
- package/src/claude_usage.py +78 -0
- package/src/provider_runtime.py +10 -3
- package/src/run_usage.py +2 -1
- package/src/session_backup.py +72 -23
- package/src/session_service.py +76 -34
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.9
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# CDX Manager 0.20.8
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Price `gpt-6-astra` usage in `cdx stats` at OpenAI's standard API list
|
|
6
|
+
rates, so Astra runs no longer appear as unpriced when provider transcripts
|
|
7
|
+
report the new model id.
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Long-context Astra requests keep the existing conservative limitation:
|
|
12
|
+
`cdx` prices the run at standard rates because usage records do not split
|
|
13
|
+
tokens per request context tier.
|
|
@@ -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.
|
|
@@ -386,6 +386,28 @@
|
|
|
386
386
|
"x86_64-pc-windows-msvc": "994a6a5767df705d6c6335bc11294990b2527fa1688e372d8a42c87c79cafa8c",
|
|
387
387
|
"x86_64-unknown-linux-musl": "c3c9b67befccff31c0c5090a09384d0788c59cb7be3c233dd61eb0118af50988"
|
|
388
388
|
}
|
|
389
|
+
},
|
|
390
|
+
"v0.20.7": {
|
|
391
|
+
"github_tarball_sha256": "98ac812a9a18da5083a2732c963eb2e6b5dcd4eba6ba3ce7096a00f53ddee7d5",
|
|
392
|
+
"github_zip_sha256": "843221898610af2e1caaf141fa66362937700fff9fa5fc49cdab0cb80200d4b4",
|
|
393
|
+
"tray_assets": {
|
|
394
|
+
"aarch64-apple-darwin": "91e2de5ded31963717d32a4ec2bf32c6cc0d64cffbbf8a44bffe996dc25112e5",
|
|
395
|
+
"aarch64-unknown-linux-musl": "6db77669f6e3ec1078460a96511caa84236e7ed3bd86df2fe82cea353d032a62",
|
|
396
|
+
"x86_64-apple-darwin": "1a9184d71a7bbdf70cb8ed0716fca79a3a707e1e6d13cc6dbb85d5865f03bcfa",
|
|
397
|
+
"x86_64-pc-windows-msvc": "532fcdabf7ff249eafaea6518668e7a175ae8763de1f23c5abc9bb2c5d4e5eac",
|
|
398
|
+
"x86_64-unknown-linux-musl": "224277ab55219bb5d94386f57fa00bc043537ad97ecf04fd68afd2a7986caa87"
|
|
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
|
+
}
|
|
389
411
|
}
|
|
390
412
|
}
|
|
391
413
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -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/provider_runtime.py
CHANGED
|
@@ -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 .
|
|
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
|
-
|
|
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):
|
package/src/run_usage.py
CHANGED
|
@@ -180,6 +180,7 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
180
180
|
"claude-haiku-4-5": {"input": 1.0, "output": 5.0},
|
|
181
181
|
"claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
|
|
182
182
|
# OpenAI
|
|
183
|
+
"gpt-6-astra": {"input": 10.0, "output": 50.0},
|
|
183
184
|
"gpt-5.6": {"input": 4.0, "output": 20.0},
|
|
184
185
|
"gpt-5.6-sol": {"input": 4.0, "output": 20.0},
|
|
185
186
|
"gpt-5.6-terra": {"input": 2.0, "output": 12.0},
|
|
@@ -194,7 +195,7 @@ DEFAULT_TOKEN_PRICES = {
|
|
|
194
195
|
"gpt-5.4-nano-2026-03-17": {"input": 0.2, "output": 1.25},
|
|
195
196
|
"gpt-5.3-codex": {"input": 1.75, "output": 14.0},
|
|
196
197
|
}
|
|
197
|
-
TOKEN_PRICES_REVIEWED = "2026-
|
|
198
|
+
TOKEN_PRICES_REVIEWED = "2026-09-05"
|
|
198
199
|
#: How long a review stays fresh before the staleness test asks for another.
|
|
199
200
|
TOKEN_PRICES_MAX_AGE_DAYS = 90
|
|
200
201
|
TOKEN_PRICES_ENV = "CDX_TOKEN_PRICES"
|
package/src/session_backup.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
if
|
|
337
|
-
remove_tree(
|
|
384
|
+
else:
|
|
385
|
+
if recovery_dir:
|
|
386
|
+
remove_tree(recovery_dir, ignore_errors=True)
|
|
338
387
|
|
|
339
388
|
return {
|
|
340
389
|
"path": file_path,
|
package/src/session_service.py
CHANGED
|
@@ -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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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)
|
|
562
|
-
os.
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
"
|
|
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
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
raise CdxError(f"
|
|
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
|
|