cdx-manager 0.20.9 → 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 +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_10.md +71 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +78 -22
- 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 +20 -0
- 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
|
|
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.
|
|
@@ -408,6 +408,17 @@
|
|
|
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
|
+
}
|
|
411
422
|
}
|
|
412
423
|
}
|
|
413
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:
|
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
|
package/src/commands/runs.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import importlib.util
|
|
6
7
|
import os
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
@@ -147,9 +148,22 @@ def _cdx_self_command():
|
|
|
147
148
|
script has no reliable executable bit on Windows (`os.access(X_OK)` is true
|
|
148
149
|
for any existing file there) and a shebang script handed to CreateProcess
|
|
149
150
|
does not start at all.
|
|
151
|
+
|
|
152
|
+
Named from the top-level package, not this module's own package: `cli` lives
|
|
153
|
+
beside `commands`, so `{__package__}.cli` resolves to a module that does not
|
|
154
|
+
exist. The import root is the parent of every package component, which for
|
|
155
|
+
src/commands/runs.py is the repository root rather than src/.
|
|
150
156
|
"""
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
package = (__package__ or "src.commands").split(".")[0]
|
|
158
|
+
module = f"{package}.cli"
|
|
159
|
+
package_root = os.path.abspath(__file__)
|
|
160
|
+
for _ in range((__package__ or "src.commands").count(".") + 2):
|
|
161
|
+
package_root = os.path.dirname(package_root)
|
|
162
|
+
if importlib.util.find_spec(module) is None:
|
|
163
|
+
# A wrong module name reports a launched run that never runs, so fail
|
|
164
|
+
# here where the caller still gets the error.
|
|
165
|
+
raise CdxError(f"Cannot launch a detached run: the cdx CLI module {module} was not found.")
|
|
166
|
+
return [sys.executable, "-m", module], package_root
|
|
153
167
|
|
|
154
168
|
def _detached_spawn_options():
|
|
155
169
|
"""Platform flags that keep the child alive after this process exits.
|
|
@@ -751,6 +765,7 @@ def handle_run(rest, ctx):
|
|
|
751
765
|
return error.exit_code or 1
|
|
752
766
|
except CdxError as error:
|
|
753
767
|
run_info = getattr(error, "run_info", None)
|
|
768
|
+
cancelled = bool(getattr(error, "cancelled", False))
|
|
754
769
|
final_payload = run_result_payload(
|
|
755
770
|
API_SCHEMA_VERSION,
|
|
756
771
|
False,
|
|
@@ -758,14 +773,16 @@ def handle_run(rest, ctx):
|
|
|
758
773
|
locals().get("session"),
|
|
759
774
|
run_info=run_info,
|
|
760
775
|
error=error,
|
|
761
|
-
error_source="cdx",
|
|
762
|
-
error_code=run_cdx_error_code(error),
|
|
776
|
+
error_source="provider" if cancelled else "cdx",
|
|
777
|
+
error_code="run_cancelled" if cancelled else run_cdx_error_code(error),
|
|
763
778
|
selection_row=selection_row,
|
|
764
779
|
)
|
|
765
780
|
if registry and run_id:
|
|
766
781
|
registry.finish(
|
|
767
782
|
run_id,
|
|
768
|
-
|
|
783
|
+
# A cancelled run is neither a task failure nor a timeout, and a
|
|
784
|
+
# caller has to be able to tell the three apart.
|
|
785
|
+
status="cancelled" if cancelled else "failed",
|
|
769
786
|
final_payload=final_payload,
|
|
770
787
|
run_info=run_info,
|
|
771
788
|
error=final_payload.get("error"),
|
package/src/context_store.py
CHANGED
|
@@ -2,6 +2,7 @@ import hashlib
|
|
|
2
2
|
import os
|
|
3
3
|
import shlex
|
|
4
4
|
import subprocess
|
|
5
|
+
import threading
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from urllib.parse import quote, unquote
|
|
7
8
|
|
|
@@ -105,13 +106,31 @@ def append_context(base_dir, note, cwd=None):
|
|
|
105
106
|
return append_context_path(get_context_path(base_dir, cwd), note)
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
# An append is a read-modify-write, and the write is atomic per attempt, so two
|
|
110
|
+
# concurrent appends that both read the pre-existing text produce one file that
|
|
111
|
+
# is missing a note the caller was told had been accepted. Parallel agents append
|
|
112
|
+
# to the same scope by design, so the sequence is serialized here.
|
|
113
|
+
_APPEND_LOCKS = {}
|
|
114
|
+
_APPEND_LOCKS_GUARD = threading.Lock()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _append_lock(path):
|
|
118
|
+
key = os.path.abspath(path)
|
|
119
|
+
with _APPEND_LOCKS_GUARD:
|
|
120
|
+
lock = _APPEND_LOCKS.get(key)
|
|
121
|
+
if lock is None:
|
|
122
|
+
lock = _APPEND_LOCKS[key] = threading.Lock()
|
|
123
|
+
return lock
|
|
124
|
+
|
|
125
|
+
|
|
108
126
|
def append_context_path(path, note):
|
|
109
127
|
note = str(note or "").strip()
|
|
110
128
|
if not note:
|
|
111
129
|
raise CdxError("Memory append requires text.")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
with _append_lock(path):
|
|
131
|
+
current = read_context_path(path).rstrip()
|
|
132
|
+
content = f"{current}\n{note}" if current else note
|
|
133
|
+
return write_context_path(path, content)
|
|
115
134
|
|
|
116
135
|
|
|
117
136
|
def init_context(base_dir, cwd=None):
|
package/src/provider_runtime.py
CHANGED
|
@@ -1023,6 +1023,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1023
1023
|
|
|
1024
1024
|
child = None
|
|
1025
1025
|
timed_out = False
|
|
1026
|
+
cancelled = False
|
|
1026
1027
|
with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
|
|
1027
1028
|
open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
|
|
1028
1029
|
options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
|
|
@@ -1056,6 +1057,25 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1056
1057
|
except subprocess.TimeoutExpired:
|
|
1057
1058
|
timed_out = True
|
|
1058
1059
|
_terminate_child_tree(child)
|
|
1060
|
+
except KeyboardInterrupt:
|
|
1061
|
+
# The provider has its own process group, so unwinding only this
|
|
1062
|
+
# supervisor would leave it consuming quota and writing to the
|
|
1063
|
+
# workspace. Terminate and reap it, then report the cancellation so
|
|
1064
|
+
# the run still gets a terminal record.
|
|
1065
|
+
cancelled = True
|
|
1066
|
+
_terminate_child_tree(child)
|
|
1067
|
+
|
|
1068
|
+
if cancelled:
|
|
1069
|
+
_combine_headless_transcript(paths)
|
|
1070
|
+
returncode = getattr(child, "returncode", None)
|
|
1071
|
+
cdx_error = CdxError(f"{spec['label']} run was cancelled.", 130)
|
|
1072
|
+
cdx_error.cancelled = True
|
|
1073
|
+
cdx_error.run_info = {
|
|
1074
|
+
**_headless_run_info(paths, spec, start_time, 130 if returncode in (None, 0) else returncode),
|
|
1075
|
+
"pid": getattr(child, "pid", None),
|
|
1076
|
+
"cancelled": True,
|
|
1077
|
+
}
|
|
1078
|
+
raise cdx_error
|
|
1059
1079
|
|
|
1060
1080
|
_combine_headless_transcript(paths)
|
|
1061
1081
|
end_time = datetime.now(timezone.utc)
|
package/src/session_backup.py
CHANGED
|
@@ -12,7 +12,11 @@ import shutil
|
|
|
12
12
|
import tempfile
|
|
13
13
|
|
|
14
14
|
from .backup_bundle import decode_bundle, encode_bundle
|
|
15
|
-
from .claude_credentials import
|
|
15
|
+
from .claude_credentials import (
|
|
16
|
+
delete_keychain_credentials,
|
|
17
|
+
read_keychain_credentials,
|
|
18
|
+
write_keychain_credentials,
|
|
19
|
+
)
|
|
16
20
|
from .config import PROVIDER_CLAUDE
|
|
17
21
|
from .errors import CdxError
|
|
18
22
|
from .fs_utils import atomic_write, remove_tree
|
|
@@ -160,6 +164,19 @@ def _resolve_session_subset(store, session_names):
|
|
|
160
164
|
selected.append(session)
|
|
161
165
|
return selected
|
|
162
166
|
|
|
167
|
+
def _restore_import_credential(auth_home, credential):
|
|
168
|
+
"""Put the destination's own Claude credential back after a failed import.
|
|
169
|
+
|
|
170
|
+
The snapshot is held in memory for the length of the import rather than
|
|
171
|
+
written beside the profile: a rollback only has to survive this process, and
|
|
172
|
+
a plaintext token in a temporary directory would be a worse trade.
|
|
173
|
+
"""
|
|
174
|
+
if credential is None:
|
|
175
|
+
return
|
|
176
|
+
if read_keychain_credentials(auth_home) == credential:
|
|
177
|
+
return
|
|
178
|
+
write_keychain_credentials(auth_home, credential)
|
|
179
|
+
|
|
163
180
|
def _restore_import_backup(store, name, backup_root, session_root, old_record, old_state):
|
|
164
181
|
if os.path.exists(session_root):
|
|
165
182
|
remove_tree(session_root)
|
|
@@ -258,6 +275,7 @@ def import_bundle(base_dir, store, file_path,
|
|
|
258
275
|
if missing_names:
|
|
259
276
|
raise CdxError(f"Bundle does not contain requested sessions: {', '.join(missing_names)}")
|
|
260
277
|
names = [item["name"] for item in imported_sessions]
|
|
278
|
+
retained_credential_names = []
|
|
261
279
|
|
|
262
280
|
existing = {session["name"] for session in list_sessions(store)}
|
|
263
281
|
conflicts = [name for name in names if name in existing]
|
|
@@ -313,6 +331,17 @@ def import_bundle(base_dir, store, file_path,
|
|
|
313
331
|
remove_tree(recovery_dir, ignore_errors=True)
|
|
314
332
|
raise
|
|
315
333
|
|
|
334
|
+
# A keychain-only profile has no credential file, so the collision with a
|
|
335
|
+
# bundle credential exists at the credential-backend level and has to be
|
|
336
|
+
# resolved there. Read it strictly: refusing an unreadable keychain is
|
|
337
|
+
# better than importing over a credential we could not preserve.
|
|
338
|
+
bundle_carries_credential = provider == PROVIDER_CLAUDE and any(
|
|
339
|
+
item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH for item in decoded_profiles.get(name, [])
|
|
340
|
+
)
|
|
341
|
+
local_credential = None
|
|
342
|
+
if bundle_carries_credential and old_record is not None:
|
|
343
|
+
local_credential = read_keychain_credentials(auth_home)
|
|
344
|
+
|
|
316
345
|
existing_state_before = None
|
|
317
346
|
try:
|
|
318
347
|
_ensure_private_dir(session_root)
|
|
@@ -366,6 +395,11 @@ def import_bundle(base_dir, store, file_path,
|
|
|
366
395
|
# In merge mode, skip files that already exist locally.
|
|
367
396
|
if is_existing and merge and os.path.lexists(dest_path):
|
|
368
397
|
continue
|
|
398
|
+
if merge and local_credential is not None and item["path"] == CLAUDE_CREDENTIALS_BUNDLE_PATH:
|
|
399
|
+
# Merge keeps local values, and the live keychain entry is this
|
|
400
|
+
# profile's credential even though no file represents it.
|
|
401
|
+
retained_credential_names.append(name)
|
|
402
|
+
continue
|
|
369
403
|
_ensure_private_dir(os.path.dirname(dest_path))
|
|
370
404
|
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
371
405
|
atomic_write(dest_path, item["content"], mode=0o600)
|
|
@@ -374,10 +408,20 @@ def import_bundle(base_dir, store, file_path,
|
|
|
374
408
|
delete_keychain_credentials(auth_home)
|
|
375
409
|
_restore_force_import_profile_paths(name, backup_root, session_root, preserved_profile_paths)
|
|
376
410
|
except Exception:
|
|
411
|
+
incomplete = []
|
|
377
412
|
try:
|
|
378
413
|
_restore_import_backup(store, name, backup_root, session_root, old_record, old_state)
|
|
379
414
|
except Exception:
|
|
380
|
-
|
|
415
|
+
incomplete.append("local files, the session record, and state")
|
|
416
|
+
try:
|
|
417
|
+
_restore_import_credential(auth_home, local_credential)
|
|
418
|
+
except Exception:
|
|
419
|
+
incomplete.append("Claude keychain authentication")
|
|
420
|
+
if incomplete:
|
|
421
|
+
raise CdxError(
|
|
422
|
+
f"Import failed and recovery is incomplete for {name}: could not restore "
|
|
423
|
+
f"{' and '.join(incomplete)}. Recovery data retained at {recovery_dir or session_root}."
|
|
424
|
+
) from None
|
|
381
425
|
if recovery_dir:
|
|
382
426
|
remove_tree(recovery_dir, ignore_errors=True)
|
|
383
427
|
raise
|
|
@@ -389,4 +433,5 @@ def import_bundle(base_dir, store, file_path,
|
|
|
389
433
|
"path": file_path,
|
|
390
434
|
"session_names": names,
|
|
391
435
|
"include_auth": bool(decoded["meta"].get("include_auth")),
|
|
436
|
+
"retained_local_credentials": retained_credential_names,
|
|
392
437
|
}
|
package/src/tray_install.py
CHANGED
|
@@ -226,7 +226,15 @@ def install(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, targe
|
|
|
226
226
|
# Best-effort, and after the companion is known to be on disk: the shortcut
|
|
227
227
|
# points at it. A failure here does not fail the install — the tray runs
|
|
228
228
|
# either way, only its toasts would go nowhere, and doctor reports that.
|
|
229
|
-
|
|
229
|
+
#
|
|
230
|
+
# Only the install that is about to be live writes it. A staged executable
|
|
231
|
+
# is going to move, and a shortcut recorded against the staged path is a
|
|
232
|
+
# broken Start Menu entry the moment it does; the promotion writes it
|
|
233
|
+
# against the final path instead.
|
|
234
|
+
shortcut = (
|
|
235
|
+
create_shortcut(executable, env=env) if record
|
|
236
|
+
else {"created": False, "path": None, "reason": "staged install; the promotion writes the shortcut"}
|
|
237
|
+
)
|
|
230
238
|
|
|
231
239
|
state = {
|
|
232
240
|
"version": STATE_VERSION,
|
|
@@ -292,9 +300,9 @@ def align_companion(
|
|
|
292
300
|
try:
|
|
293
301
|
result = update(
|
|
294
302
|
base_dir, version,
|
|
295
|
-
download=download, ledger_path=ledger_path, target=target, probe=probe,
|
|
303
|
+
download=download, ledger_path=ledger_path, target=target, probe=probe, env=env,
|
|
296
304
|
)
|
|
297
|
-
except TrayInstallError as error:
|
|
305
|
+
except (TrayInstallError, OSError) as error:
|
|
298
306
|
outcome = {
|
|
299
307
|
"aligned": False,
|
|
300
308
|
"reason": str(error),
|
|
@@ -407,7 +415,8 @@ def staged_dir(base_dir):
|
|
|
407
415
|
return os.path.join(state_dir(base_dir), "companion.staged")
|
|
408
416
|
|
|
409
417
|
|
|
410
|
-
def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, probe=None
|
|
418
|
+
def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, probe=None,
|
|
419
|
+
env=None):
|
|
411
420
|
"""Replace an installed companion without ever being left with none.
|
|
412
421
|
|
|
413
422
|
The order is the whole point. The replacement is downloaded, verified and
|
|
@@ -444,18 +453,43 @@ def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target
|
|
|
444
453
|
live = os.path.join(state_dir(base_dir), "companion")
|
|
445
454
|
retired = previous_dir(base_dir)
|
|
446
455
|
shutil.rmtree(retired, ignore_errors=True)
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
456
|
+
retired_live = False
|
|
457
|
+
try:
|
|
458
|
+
if os.path.isdir(live):
|
|
459
|
+
os.rename(live, retired)
|
|
460
|
+
retired_live = True
|
|
461
|
+
os.rename(staged, live)
|
|
462
|
+
except OSError as error:
|
|
463
|
+
# A failure between the two renames used to leave the recorded
|
|
464
|
+
# executable pointing at nothing: the install claimed a live companion
|
|
465
|
+
# and the path was empty. Put the proven one back, and report as a
|
|
466
|
+
# TrayInstallError so the caller's restart path runs.
|
|
467
|
+
restored = True
|
|
468
|
+
if retired_live and not os.path.isdir(live):
|
|
469
|
+
try:
|
|
470
|
+
os.rename(retired, live)
|
|
471
|
+
except OSError:
|
|
472
|
+
restored = False
|
|
473
|
+
shutil.rmtree(staged, ignore_errors=True)
|
|
474
|
+
raise TrayInstallError(
|
|
475
|
+
f"Could not promote the staged tray companion for CDX {version}: {error}. "
|
|
476
|
+
+ ("The installed one was restored." if restored
|
|
477
|
+
else f"The previous companion is retained at {retired}.")
|
|
478
|
+
) from error
|
|
450
479
|
# The retired copy stays. It used to be deleted here, which left nothing to
|
|
451
480
|
# go back to the moment the replacement turned out not to start — and that
|
|
452
481
|
# is precisely when it is needed. The next update clears it, so at most one
|
|
453
482
|
# extra copy is ever on disk.
|
|
454
483
|
|
|
484
|
+
live_executable = fresh["executable"].replace(staged, live, 1)
|
|
485
|
+
# Written now, against the path that will still exist afterwards, and
|
|
486
|
+
# recorded so uninstall keeps owning it.
|
|
487
|
+
shortcut = create_shortcut(live_executable, env=env)
|
|
455
488
|
state = {
|
|
456
489
|
**fresh,
|
|
457
|
-
"executable":
|
|
458
|
-
"paths": [live],
|
|
490
|
+
"executable": live_executable,
|
|
491
|
+
"paths": [live] + ([shortcut["path"]] if shortcut["created"] else []),
|
|
492
|
+
"shortcut": shortcut["path"] if shortcut["created"] else None,
|
|
459
493
|
}
|
|
460
494
|
_record_state(base_dir, state)
|
|
461
495
|
return {"state": state, "replaced": previous.get("cdx_version"), "previous_state": previous}
|
|
@@ -496,17 +530,55 @@ def restore_previous(base_dir, previous_state):
|
|
|
496
530
|
return False
|
|
497
531
|
|
|
498
532
|
|
|
533
|
+
# `--print` exits non-zero when CDX itself is unavailable, and that is a
|
|
534
|
+
# diagnostic that ran, not a companion that failed to launch. These are the
|
|
535
|
+
# codes that mean the program never got to run: the exec conventions for "found
|
|
536
|
+
# but not executable" and "not found". Death by signal is reported as a
|
|
537
|
+
# negative returncode and counts the same way.
|
|
538
|
+
_PROBE_LAUNCH_FAILURE_CODES = (126, 127)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def probe_command(executable):
|
|
542
|
+
"""How to ask the companion itself for its diagnostic output.
|
|
543
|
+
|
|
544
|
+
Not `launch_command`: on macOS that hands the bundle to `open`, which takes
|
|
545
|
+
`--print` for itself and reports nothing whatsoever about the companion. The
|
|
546
|
+
binary inside the bundle is what has to run, and an app bundle with no
|
|
547
|
+
runnable binary in it is exactly the case this gate exists to refuse.
|
|
548
|
+
"""
|
|
549
|
+
if platform.system() == "Darwin" and executable.endswith(".app"):
|
|
550
|
+
macos_dir = os.path.join(executable, "Contents", "MacOS")
|
|
551
|
+
try:
|
|
552
|
+
entries = sorted(os.listdir(macos_dir))
|
|
553
|
+
except OSError:
|
|
554
|
+
return None
|
|
555
|
+
for entry in entries:
|
|
556
|
+
candidate = os.path.join(macos_dir, entry)
|
|
557
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
558
|
+
return [candidate]
|
|
559
|
+
return None
|
|
560
|
+
return [executable]
|
|
561
|
+
|
|
562
|
+
|
|
499
563
|
def _probe(executable):
|
|
500
|
-
"""Does this companion
|
|
501
|
-
|
|
564
|
+
"""Does this companion actually run?
|
|
565
|
+
|
|
566
|
+
`--print` makes it emit its snapshot and exit, so a bundle that cannot
|
|
567
|
+
launch — wrong architecture, missing library, no binary inside it — is
|
|
568
|
+
refused here rather than after the working companion is gone.
|
|
569
|
+
"""
|
|
570
|
+
command = probe_command(executable)
|
|
571
|
+
if not command:
|
|
572
|
+
return False
|
|
502
573
|
try:
|
|
503
|
-
subprocess.run( # noqa: S603 (path comes from our own install state)
|
|
504
|
-
|
|
574
|
+
completed = subprocess.run( # noqa: S603 (path comes from our own install state)
|
|
575
|
+
command + ["--print"],
|
|
505
576
|
capture_output=True, timeout=20, check=False,
|
|
506
577
|
)
|
|
507
578
|
except (OSError, subprocess.SubprocessError):
|
|
508
579
|
return False
|
|
509
|
-
|
|
580
|
+
returncode = completed.returncode
|
|
581
|
+
return not (returncode < 0 or returncode in _PROBE_LAUNCH_FAILURE_CODES)
|
|
510
582
|
|
|
511
583
|
|
|
512
584
|
def interrupted_update(base_dir):
|