cdx-manager 0.20.9 → 0.20.11
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 +51 -4
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_10.md +71 -0
- package/changelogs/CHANGELOGS_0_20_11.md +37 -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_usage.py +27 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +1 -1
- package/src/cli_commands.py +0 -4
- package/src/cli_helpers.py +1 -212
- package/src/commands/backup.py +5 -0
- package/src/commands/launch.py +84 -54
- package/src/commands/runs.py +22 -5
- package/src/context_store.py +22 -3
- package/src/handoff.py +267 -0
- package/src/provider_runtime.py +26 -3
- package/src/session_backup.py +47 -2
- 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
|
|
|
@@ -106,9 +106,56 @@ The reason `cdx` exists: knowing, across every account you own, which one you ca
|
|
|
106
106
|
|
|
107
107
|
### Context handoff
|
|
108
108
|
|
|
109
|
-
- **
|
|
109
|
+
- **Transcript-first handoff.** Switch accounts or providers using the source conversation's recorded native identity and verified workspace. CDX never chooses a conversation by file modification time. Missing, stale or mismatched identity requires an explicit choice; JSON/non-interactive callers receive candidate diagnostics instead of a launch.
|
|
110
|
+
- **Instructions before action.** The receiving agent is told to read applicable `AGENTS.md`/`CLAUDE.md` and their required references (including Logics instructions), recover the task from bounded portions of the complete transcript, verify current state, then show a concise objective/state/next-action checkpoint before modifying anything. Existing clear authorization still permits continuation. CDX supplies this prompt; it cannot certify that an agent has understood or obeyed it.
|
|
111
|
+
- **Shared notes remain yours.** `cdx context` and `cdx memory` remain explicit notes. Automatic handoff does not overwrite them or copy authentication. A dated reference to workspace notes is supplementary, never a substitute for the native conversation.
|
|
110
112
|
- **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
|
|
111
113
|
|
|
114
|
+
Run handoff from the intended project directory:
|
|
115
|
+
|
|
116
|
+
```sh
|
|
117
|
+
cd /path/to/project
|
|
118
|
+
cdx handoff source target --json # prepare only, no provider launch
|
|
119
|
+
cdx handoff target # use that prepared entry and launch
|
|
120
|
+
cdx handoff source target --source-conversation CONVERSATION_ID
|
|
121
|
+
# Explicit fallback when only a terminal capture is available:
|
|
122
|
+
cdx handoff source target --source-transcript /path/to/source/log/cdx-session.log
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The last two source-selection options are mutually exclusive and apply only to
|
|
126
|
+
`SOURCE TARGET`. Without a usable recorded identity, an interactive terminal offers
|
|
127
|
+
up to 20 valid conversations from the current workspace, ordered by actual event
|
|
128
|
+
time for inspection, with no default choice. Blank input or Ctrl-C cancels. A stale
|
|
129
|
+
recorded identity never silently falls back, even if there is only one candidate.
|
|
130
|
+
Run from the conversation's original workspace if its metadata names another directory.
|
|
131
|
+
|
|
132
|
+
Each preparation writes a private, unique `<target-auth-home>/handoffs/<uuid>.json`
|
|
133
|
+
entry. `context.target_path` in JSON output names that entry; `handoff` contains its
|
|
134
|
+
metadata, including recovery mode, source conversation ID, transcript path, workspace,
|
|
135
|
+
preparation time, readable byte extent and SHA-256. `source_session`/`target_session`
|
|
136
|
+
contain names/providers only. Transcript bodies and credentials are not emitted.
|
|
137
|
+
Unresolved JSON source selection returns `ok: false`, error code
|
|
138
|
+
`handoff_source_unresolved`, a bounded `candidates` array and a nonzero exit status.
|
|
139
|
+
|
|
140
|
+
The original JSONL remains in place, including early decisions, tool calls/results
|
|
141
|
+
and compaction records. Read it in bounded portions up to `extent_bytes`; there is
|
|
142
|
+
no 120000-character tail replacement. An unfinished final JSONL record is excluded
|
|
143
|
+
from that preparation. Later appends do not change its boundary. Reusing an entry
|
|
144
|
+
checks the original bytes: deletion, truncation or rewriting requires a new handoff,
|
|
145
|
+
not a silent downgrade to notes. Keep the original source profile/transcript available
|
|
146
|
+
until recovery completes. No transcript copy or automatic artifact cleanup is performed;
|
|
147
|
+
entries can be removed after use. A small per-workspace/target pointer chooses the most
|
|
148
|
+
recent preparation for the one-name command; concurrent launches retain their own entry
|
|
149
|
+
path and cannot overwrite each other's entry.
|
|
150
|
+
|
|
151
|
+
Legacy one-name handoff without a prepared entry reads workspace notes in `notes-only`
|
|
152
|
+
mode and explicitly reports that native history was not recovered. Terminal fallback
|
|
153
|
+
requires a named file from that source session's launch logs and is labelled `degraded`:
|
|
154
|
+
it cannot prove complete history or tools, and its workspace is operator-selected rather
|
|
155
|
+
than verified from native metadata. Native resolution currently supports Codex and Claude.
|
|
156
|
+
All modes ask the recipient to report material gaps, and none bypass provider access rules
|
|
157
|
+
or turn historical tool output into new authorization.
|
|
158
|
+
|
|
112
159
|
### Maintenance
|
|
113
160
|
|
|
114
161
|
- **Launch history.** Inspect recent launches with provider, result, duration, working directory, launch settings, and transcript path.
|
|
@@ -515,8 +562,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
515
562
|
| `cdx can-resume <name> [--json]` | Check whether a session supports native resume without launching the provider |
|
|
516
563
|
| `cdx context show\|path\|init\|edit\|clear\|set\|append [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
517
564
|
| `cdx memory [--global\|--project NAME_OR_PATH] [show\|view\|path\|init\|edit\|clear\|set\|append\|list] [text...] [--json]` | Manage explicit user-controlled memory for the current workspace, global scope, or a named/path project |
|
|
518
|
-
| `cdx handoff <name> [--json]` |
|
|
519
|
-
| `cdx handoff <source> <target> [--json]` |
|
|
565
|
+
| `cdx handoff <name> [--json]` | Reuse the prepared entry for this target/workspace, or use explicitly labelled notes-only recovery if none exists; launch unless `--json` |
|
|
566
|
+
| `cdx handoff <source> <target> [--source-conversation ID\|--source-transcript PATH] [--json]` | Prepare an identity-checked transcript entry and launch the target in the verified workspace unless `--json`; explicit terminal selection is degraded recovery |
|
|
520
567
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
521
568
|
| `cdx clean [name] [--yes] [--json]` | Clear launch transcript logs for one session or all sessions after confirmation |
|
|
522
569
|
| `cdx clean profiles (--tmp\|--old-logs DAYS) [--yes] [--json]` | Remove explicit profile cleanup candidates after confirmation: temporary marketplace/plugin staging caches or old `.log` files |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.11
|
|
@@ -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,37 @@
|
|
|
1
|
+
# CDX Manager 0.20.11
|
|
2
|
+
|
|
3
|
+
## Reliable handoff recovery
|
|
4
|
+
|
|
5
|
+
- Resolve Codex and Claude handoffs using the recorded native conversation ID and
|
|
6
|
+
verify the workspace. An old transcript touched recently can no longer silently
|
|
7
|
+
replace the intended conversation. Missing or stale identities return candidates;
|
|
8
|
+
use `--source-conversation ID` to choose explicitly.
|
|
9
|
+
- Give the receiving agent access to the complete native transcript, including
|
|
10
|
+
earlier decisions and tool evidence, instead of a flattened 120000-character tail.
|
|
11
|
+
Each handoff has its own private entry with provenance, a preparation boundary and
|
|
12
|
+
a checksum. Reusing an entry detects a deleted or rewritten source.
|
|
13
|
+
- Require the receiving agent to read repository instructions and their references,
|
|
14
|
+
recover user decisions and authorization limits, verify current state, and show a
|
|
15
|
+
concise recovery checkpoint before making changes. This is a prompt contract, not
|
|
16
|
+
a guarantee of agent comprehension.
|
|
17
|
+
- Preserve workspace notes as supplementary context rather than overwriting them
|
|
18
|
+
during handoff. Concurrent preparations retain separate entries; the target starts
|
|
19
|
+
in the verified workspace.
|
|
20
|
+
|
|
21
|
+
## Compatibility notes
|
|
22
|
+
|
|
23
|
+
- `cdx handoff SOURCE TARGET --json` prepares without launching. JSON includes the
|
|
24
|
+
entry path and source metadata, not transcript bodies or authentication data.
|
|
25
|
+
- `cdx handoff TARGET` reuses the prepared entry for that target and workspace. With
|
|
26
|
+
no prepared entry, existing workspace notes remain available as explicitly labelled
|
|
27
|
+
notes-only recovery.
|
|
28
|
+
- Automatic newest-log fallback is removed. For an intentional terminal-only
|
|
29
|
+
fallback, pass `--source-transcript PATH` naming a source session launch log. This
|
|
30
|
+
degraded mode cannot verify complete native history or the original workspace.
|
|
31
|
+
- Keep the original transcript available until recovery completes. The entry refers
|
|
32
|
+
to it in place; it is not a transcript backup.
|
|
33
|
+
|
|
34
|
+
## Validation
|
|
35
|
+
|
|
36
|
+
- 1048 Python tests pass, including synthetic handoff regressions and mocked launches.
|
|
37
|
+
- Project lint and Logics corpus validation pass.
|
|
@@ -408,6 +408,28 @@
|
|
|
408
408
|
"x86_64-pc-windows-msvc": "343245a6ce52275e8e7bd9a380d9e03fd1db3bcf917df3e6cecde2d0be1bfaeb",
|
|
409
409
|
"x86_64-unknown-linux-musl": "5d6e776b5ce64a062c798c5c00f9e85e003b5b6888cece74173bff1f45247f34"
|
|
410
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
|
+
}
|
|
422
|
+
},
|
|
423
|
+
"v0.20.10": {
|
|
424
|
+
"github_tarball_sha256": "2b97c8dedc4740d89b8dab5b4ea54c986d211efdf2f9d6c8cbcf014a42bc7eb2",
|
|
425
|
+
"github_zip_sha256": "144cb5987498a96d78299c1589735b5aef5dd31cd3abfe638c541e83464055b5",
|
|
426
|
+
"tray_assets": {
|
|
427
|
+
"aarch64-apple-darwin": "7f89f616c707bfef9f2a0fdccae6e37c22d3b2fb51f6ea2cb5cd5dc8f86446fe",
|
|
428
|
+
"aarch64-unknown-linux-musl": "4295fb3fd27abacae614360783b704e87d8390023843a32e1626ce986004078f",
|
|
429
|
+
"x86_64-apple-darwin": "f68422c8da7316ba575579cf689056b58c23213717203a8ea6c48e26337ae2e7",
|
|
430
|
+
"x86_64-pc-windows-msvc": "ae8eba39b1d9d7e9b97c3b3026561360a6ec00ca154b5d4d215a283fc5566050",
|
|
431
|
+
"x86_64-unknown-linux-musl": "cfa4245e7704a4c85e4f680275db500c600e46a44aac19d1ec5675deb2be0c58"
|
|
432
|
+
}
|
|
411
433
|
}
|
|
412
434
|
}
|
|
413
435
|
}
|
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:
|
package/src/claude_usage.py
CHANGED
|
@@ -91,6 +91,32 @@ def _read_claude_credentials(auth_home):
|
|
|
91
91
|
return None
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
def claude_profile_store_dir(auth_home):
|
|
95
|
+
"""Where `ANTHROPIC_CONFIG_DIR` points: the credential directory, not the home.
|
|
96
|
+
|
|
97
|
+
Claude Code treats `ANTHROPIC_CONFIG_DIR` as the root of its profile store
|
|
98
|
+
and refuses every write beneath it, so whatever this returns is denied to
|
|
99
|
+
the agent's own file-writing tools. Pointing it at `auth_home` denied the
|
|
100
|
+
entire profile home -- `.claude/projects/*/memory`, `.claude/settings.json`,
|
|
101
|
+
`.npm`, `.cargo` -- which is far more than a sign-in store and cost agents
|
|
102
|
+
their memory and settings with no gain.
|
|
103
|
+
|
|
104
|
+
No gain, because the variable does not locate credentials: measured on
|
|
105
|
+
Claude Code 2.1.268, `HOME` alone authenticates and this variable alone does
|
|
106
|
+
not. It only draws the deny boundary, so it belongs on the directory that
|
|
107
|
+
actually holds a credential -- `_write_claude_oauth_token` puts the
|
|
108
|
+
setup-token fallback in `auth_home/credentials/default.json`.
|
|
109
|
+
|
|
110
|
+
Not covered, and deliberately: `auth_home/.claude/.credentials.json`, which
|
|
111
|
+
Claude Code writes where no OS keyring answers. It cannot be: it sits under
|
|
112
|
+
`.claude` beside the memory and settings this narrowing exists to free, and
|
|
113
|
+
`_secure_storage_overrides` explains why that directory cannot move. It
|
|
114
|
+
keeps its 0600 mode; on macOS it does not exist at all, the login keychain
|
|
115
|
+
holding the credential instead.
|
|
116
|
+
"""
|
|
117
|
+
return os.path.join(auth_home, "credentials")
|
|
118
|
+
|
|
119
|
+
|
|
94
120
|
def _link_macos_keychain(auth_home):
|
|
95
121
|
"""Make the operator's login keychain reachable from a redirected home.
|
|
96
122
|
|
|
@@ -148,7 +174,7 @@ def _secure_storage_overrides(auth_home):
|
|
|
148
174
|
def _home_env_overrides(auth_home):
|
|
149
175
|
overrides = {
|
|
150
176
|
"HOME": auth_home,
|
|
151
|
-
"ANTHROPIC_CONFIG_DIR": auth_home,
|
|
177
|
+
"ANTHROPIC_CONFIG_DIR": claude_profile_store_dir(auth_home),
|
|
152
178
|
"CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS": "1",
|
|
153
179
|
}
|
|
154
180
|
if sys.platform == "win32":
|
package/src/cli.py
CHANGED
|
@@ -260,7 +260,7 @@ def _print_help(use_color=False):
|
|
|
260
260
|
f" {_style('cdx resume <name> [--json]', '36', use_color)}",
|
|
261
261
|
f" {_style('cdx can-resume <name> [--json]', '36', use_color)}",
|
|
262
262
|
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
263
|
-
f" {_style('cdx handoff <source> <target> [--json]', '36', use_color)}",
|
|
263
|
+
f" {_style('cdx handoff <source> <target> [--source-conversation ID | --source-transcript PATH] [--json]', '36', use_color)}",
|
|
264
264
|
f" {_style('cdx add [provider] <name> [--model MODEL] [--json]', '36', use_color)}",
|
|
265
265
|
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
266
266
|
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -30,7 +30,7 @@ EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--
|
|
|
30
30
|
IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--allow-authless-force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
|
31
31
|
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text...] [--json]"
|
|
32
32
|
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
33
|
-
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
33
|
+
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-transcript PATH] [--json]"
|
|
34
34
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
35
35
|
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
|
|
36
36
|
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
|
package/src/cli_commands.py
CHANGED
|
@@ -15,20 +15,16 @@ from .cli_args import STATUS_USAGE # noqa: F401 (re-exported for cli.py)
|
|
|
15
15
|
from .cli_helpers import ( # noqa: F401 (several names are re-exported for cli.py / tests)
|
|
16
16
|
API_SCHEMA_VERSION,
|
|
17
17
|
_bootstrap_claude_setup_token,
|
|
18
|
-
_build_handoff_context,
|
|
19
18
|
_extract_claude_oauth_token,
|
|
20
19
|
_format_bytes,
|
|
21
20
|
_format_launch_config,
|
|
22
21
|
_format_launch_setting_value,
|
|
23
22
|
_format_launch_settings_hint,
|
|
24
|
-
_handoff_launch_prompt,
|
|
25
23
|
_json_failure,
|
|
26
24
|
_json_success,
|
|
27
|
-
_latest_handoff_transcript_path,
|
|
28
25
|
_local_now_iso,
|
|
29
26
|
_make_notify_progress,
|
|
30
27
|
_make_status_progress,
|
|
31
|
-
_read_handoff_transcript,
|
|
32
28
|
_resolve_confirmation,
|
|
33
29
|
_resume_capability_for_session,
|
|
34
30
|
_update_notice_warnings,
|