cdx-manager 0.21.5 → 0.21.6
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_21_6.md +33 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +2 -2
- package/docs/operations.md +1 -0
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +1 -1
- package/src/backup_bundle.py +60 -16
- package/src/claude_usage.py +1 -1
- package/src/cli.py +6 -2
- package/src/cli_args.py +43 -15
- package/src/cli_helpers.py +3 -2
- package/src/codex_usage.py +1 -1
- package/src/commands/runs.py +3 -3
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/health.py +2 -2
- package/src/notify.py +23 -18
- package/src/provider_background.py +1 -1
- package/src/provider_runtime.py +52 -5
- package/src/session_service.py +19 -34
- package/src/tray_events.py +23 -4
- package/src/tray_logics.py +1 -1
- package/src/update_all.py +1 -1
- package/src/update_check.py +1 -1
- package/src/update_manager.py +7 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
|
|
10
10
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.21.
|
|
1
|
+
0.21.6
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# CDX Manager 0.21.6
|
|
2
|
+
|
|
3
|
+
## Security
|
|
4
|
+
|
|
5
|
+
- **Windows toasts.** PowerShell also ends a single-quoted string on the typographic quotes ‘ ’ ‚ ‛, so those are now escaped too, in cdx and in the tray. A crafted folder name or response preview could run code. French text such as "l’agent" no longer drops the toast (#23).
|
|
6
|
+
- **Tray over WSL.** The tray no longer passes its commands through the WSL shell, which expanded `$(...)` and split arguments containing spaces. It also finds the user's own `cdx` instead of a stale system-wide one (#23).
|
|
7
|
+
- **`cdx set <name> --scrub-env on`** launches Claude with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`. The Bash commands, hooks and MCP servers the agent starts then no longer receive credentials, including the setup-token. It is off by default; `docs/operations.md` explains the trade-off (#26).
|
|
8
|
+
- **Backup bundles that include auth** use a stronger key derivation (scrypt N=2^17, or PBKDF2 at 600k iterations) and record its cost. Older bundles still import, and a crafted cost is refused (#25).
|
|
9
|
+
- **Claude profile creation** no longer replaces an unparseable or symlinked `settings.json`, and keeps its permissions (#23).
|
|
10
|
+
- **PyPI** releases are published with Trusted Publishing, without a stored API token (#27).
|
|
11
|
+
|
|
12
|
+
## Fixes
|
|
13
|
+
|
|
14
|
+
- **Windows:**
|
|
15
|
+
- `cdx.cmd` now returns the real exit code; it always returned 0;
|
|
16
|
+
- `cdx ready` schedules its task on any Windows language, where `schtasks` refused the date on non-US systems;
|
|
17
|
+
- provider output is read as UTF-8;
|
|
18
|
+
- `cdx -v` and `--help` work without a home directory;
|
|
19
|
+
- the check after `cdx update` no longer prints "Could not determine home directory" (#22, #23).
|
|
20
|
+
- **`cdx run`** stops the provider when it receives SIGTERM or SIGHUP (a `kill`, a `systemctl stop` or a closed SSH session), not only on Ctrl+C (#24).
|
|
21
|
+
- **No more lost writes when two commands overlap:**
|
|
22
|
+
- tray events published while another is being acknowledged are kept;
|
|
23
|
+
- `cdx context set` and `clear` no longer race with an append (#24).
|
|
24
|
+
- **`cdx set`:**
|
|
25
|
+
- the usage line, help and docs list every flag;
|
|
26
|
+
- a bad on/off value names the flag, for example "Unsupported notify value: maybe (on|off)" (#25).
|
|
27
|
+
- **`cdx schema --json`** publishes every run error code and warning code. `cdx run` sets its error codes at the source instead of reading the message text (#25).
|
|
28
|
+
|
|
29
|
+
## Housekeeping
|
|
30
|
+
|
|
31
|
+
- CI also tests Python 3.9, the minimum version declared in pyproject.
|
|
32
|
+
- The tray builds with `cargo --locked`.
|
|
33
|
+
- The test suite no longer touches the developer's real macOS keychain (#24, #25).
|
|
@@ -529,6 +529,17 @@
|
|
|
529
529
|
"x86_64-pc-windows-msvc": "0d4e6c736a789d8fddaf688cd61d899a305171fa6d7e856735b2cd7d2daef21c",
|
|
530
530
|
"x86_64-unknown-linux-musl": "8320025507801fd549b8c8dd4196a9378ddb25f137e48945e61e429bf2a864aa"
|
|
531
531
|
}
|
|
532
|
+
},
|
|
533
|
+
"v0.21.5": {
|
|
534
|
+
"github_tarball_sha256": "5a45b3ba4df72a4bba5bbd8c8c28728f7e804c67a4a7201d927f350d0276f432",
|
|
535
|
+
"github_zip_sha256": "b53d281da3540b85745a9b1e8abbdec797a0e97c1e1ec098d8c98753ffc334c2",
|
|
536
|
+
"tray_assets": {
|
|
537
|
+
"aarch64-apple-darwin": "e97c99054653a209b84190c2f08d3b1739527dc8e1d4795dd4b51271096fcdc7",
|
|
538
|
+
"aarch64-unknown-linux-musl": "04cfad61af12263eaa82f3b1fec12c8fad87762dc7a95b42e170f868156829e1",
|
|
539
|
+
"x86_64-apple-darwin": "b7a3beb55f34a9fae182ec164152d3295bf078a354c2788296c63c7c268c686e",
|
|
540
|
+
"x86_64-pc-windows-msvc": "d107e75e7700c2fcc7138d6bc14cdf6929fb3ad3e3d7a5b8648f44828f4ab4bb",
|
|
541
|
+
"x86_64-unknown-linux-musl": "00d93b4f16b58c203fbdc8cf80b15f1402ebf3fef8568162505142a3adb20272"
|
|
542
|
+
}
|
|
532
543
|
}
|
|
533
544
|
}
|
|
534
545
|
}
|
package/docs/cli.md
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
| `cdx config <name> [--json]` | Show persistent launch settings for a session |
|
|
24
24
|
| `cdx configs [--json]` | Show persistent launch settings for all sessions in one table |
|
|
25
25
|
| `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
|
|
26
|
-
| `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]` | Persist launch settings for one or more sessions |
|
|
27
|
-
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--rtk\|--logics\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
26
|
+
| `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] [--notify on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
|
|
27
|
+
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--notify-preview\|--scrub-env\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
28
28
|
| `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
29
29
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
30
30
|
| `cdx resume <name> [--json]` | Resume the provider-native conversation for a session using the named command form |
|
package/docs/operations.md
CHANGED
|
@@ -152,6 +152,7 @@ credential sync.
|
|
|
152
152
|
|
|
153
153
|
- **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first. `cdx doctor` uses the same Codex app-server probe as `cdx auth refresh`, so a local auth file alone is never reported as live authentication; a rejected refresh token is reported as `login_required` with the matching login command.
|
|
154
154
|
- **A Claude session has no claude.ai connectors (Microsoft 365, ...) in `/mcp`** — `cdx login <name>` stores a setup-token for Claude profiles, and Claude Code can only make model requests with it: it never loads claude.ai connectors. `cdx status` shows each Claude profile's auth type (`setup-token` or `full-login`; `auth_type` in `--json`). To switch, start the session and run `/login` inside it. Once the profile holds its own claude.ai login (its `.claude/.credentials.json`, or its profile-specific `Claude Code-credentials-<hash>` keychain entry on macOS), cdx stops passing the setup-token on later launches. If that login later expires, run `/login` again, or go back to a setup-token with `cdx login <name>`, which removes the profile's `/login`.
|
|
155
|
+
- **Keep credentials away from what a Claude agent runs** — by default Claude Code passes its credentials, including the setup-token cdx injects, to the Bash commands, hooks and stdio MCP servers it starts. `cdx set <name> --scrub-env on` launches that session with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`, which strips credential-looking variables from those subprocesses (GitHub tokens and proxy settings stay). It is off by default: it also removes other secrets an agent may need (`NPM_TOKEN`, a `DATABASE_URL` with a password), and on Linux it isolates Bash from host processes, so `ps` and `kill` no longer see them. Worth turning on for unattended sessions that read untrusted content.
|
|
155
156
|
- **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file`, `codex_live_auth`, and `codex_stale_auth_logs` checks. If recent logs mention expired auth, repair only that isolated profile with `cdx login <name>`. Codex Business profiles can share the same `tokens.account_id`; `cdx doctor` reports `codex_shared_account_id` as a workspace-level clue, not proof that two profiles use the same user. Newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
|
|
156
157
|
- **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
|
|
157
158
|
- **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
|
package/install.ps1
CHANGED
|
@@ -90,15 +90,18 @@ try {
|
|
|
90
90
|
Copy-Item -Path (Join-Path $sourceDir.FullName "*") -Destination $targetDir -Recurse -Force
|
|
91
91
|
|
|
92
92
|
$launcherPath = Join-Path $binDir "cdx.cmd"
|
|
93
|
+
# No parenthesized block: cmd expands %ERRORLEVEL% when it parses a block,
|
|
94
|
+
# before py runs, so `exit /b %ERRORLEVEL%` inside one always returned 0.
|
|
93
95
|
$launcher = @"
|
|
94
96
|
@echo off
|
|
95
97
|
set SCRIPT=%~dp0..\versions\$versionDir\bin\cdx
|
|
96
98
|
where py >nul 2>nul
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
if errorlevel 1 goto nopy
|
|
100
|
+
py -3 "%SCRIPT%" %*
|
|
101
|
+
exit /b %ERRORLEVEL%
|
|
102
|
+
:nopy
|
|
101
103
|
python "%SCRIPT%" %*
|
|
104
|
+
exit /b %ERRORLEVEL%
|
|
102
105
|
"@
|
|
103
106
|
Set-Content -Path $launcherPath -Value $launcher -Encoding ascii
|
|
104
107
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
package/src/backup_bundle.py
CHANGED
|
@@ -11,10 +11,16 @@ BUNDLE_SCHEMA_VERSION = 1
|
|
|
11
11
|
_SALT_BYTES = 16
|
|
12
12
|
_NONCE_BYTES = 16
|
|
13
13
|
_AEAD_NONCE_BYTES = 12
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
# New bundles record their KDF cost in `kdf_params`, at OWASP-level costs
|
|
15
|
+
# (scrypt N=2^17, PBKDF2-SHA256 600k). Bundles without the field were written
|
|
16
|
+
# at the earlier costs, which stay readable. Recorded costs are capped so a
|
|
17
|
+
# crafted bundle cannot make an import burn minutes of CPU or gigabytes of RAM.
|
|
18
|
+
_SCRYPT_PARAMS = {"n": 2 ** 17, "r": 8, "p": 1}
|
|
19
|
+
_PBKDF2_PARAMS = {"iterations": 600000}
|
|
20
|
+
_LEGACY_SCRYPT_PARAMS = {"n": 2 ** 14, "r": 8, "p": 1}
|
|
21
|
+
_LEGACY_PBKDF2_PARAMS = {"iterations": 200000}
|
|
22
|
+
_MAX_SCRYPT_N = 2 ** 20
|
|
23
|
+
_MAX_PBKDF2_ITERATIONS = 10_000_000
|
|
18
24
|
_AEAD_ALGORITHM = "aes-256-gcm"
|
|
19
25
|
_LEGACY_ALGORITHM = "sha256-xor-hmac"
|
|
20
26
|
_KDF_SCRYPT = "scrypt"
|
|
@@ -78,19 +84,49 @@ def _bundle_kdf_candidates(wrapper):
|
|
|
78
84
|
return [recorded]
|
|
79
85
|
|
|
80
86
|
|
|
81
|
-
def
|
|
87
|
+
def _default_params(kdf):
|
|
88
|
+
return dict(_SCRYPT_PARAMS if kdf == _KDF_SCRYPT else _PBKDF2_PARAMS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bundle_kdf_params(wrapper, kdf):
|
|
92
|
+
"""The recorded cost, or the pre-`kdf_params` cost for older bundles."""
|
|
93
|
+
recorded = wrapper.get("kdf_params")
|
|
94
|
+
if recorded is None:
|
|
95
|
+
return dict(_LEGACY_SCRYPT_PARAMS if kdf == _KDF_SCRYPT else _LEGACY_PBKDF2_PARAMS)
|
|
96
|
+
if not isinstance(recorded, dict) or ("n" in recorded) != (kdf == _KDF_SCRYPT):
|
|
97
|
+
return None # recorded for the other KDF: not this candidate
|
|
98
|
+
try:
|
|
99
|
+
if kdf == _KDF_SCRYPT:
|
|
100
|
+
params = {key: int(recorded[key]) for key in ("n", "r", "p")}
|
|
101
|
+
valid = (2 <= params["n"] <= _MAX_SCRYPT_N and params["n"] & (params["n"] - 1) == 0
|
|
102
|
+
and 1 <= params["r"] <= 32 and 1 <= params["p"] <= 16)
|
|
103
|
+
else:
|
|
104
|
+
params = {"iterations": int(recorded["iterations"])}
|
|
105
|
+
valid = 1 <= params["iterations"] <= _MAX_PBKDF2_ITERATIONS
|
|
106
|
+
except (TypeError, ValueError, KeyError):
|
|
107
|
+
valid = False
|
|
108
|
+
if not valid:
|
|
109
|
+
raise CdxError("Unsupported bundle key derivation parameters.")
|
|
110
|
+
return params
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _derive_key_material(passphrase, salt, kdf, params=None):
|
|
114
|
+
params = params or _default_params(kdf)
|
|
82
115
|
if kdf == _KDF_SCRYPT:
|
|
83
116
|
if not _available_kdf(_KDF_SCRYPT):
|
|
84
117
|
raise CdxError(
|
|
85
118
|
"This bundle records the scrypt key derivation function, which this Python runtime "
|
|
86
119
|
"does not provide. Decode it with a Python built against OpenSSL scrypt support."
|
|
87
120
|
)
|
|
121
|
+
n, r, p = params["n"], params["r"], params["p"]
|
|
88
122
|
return hashlib.scrypt(
|
|
89
123
|
passphrase,
|
|
90
124
|
salt=salt,
|
|
91
|
-
n=
|
|
92
|
-
r=
|
|
93
|
-
p=
|
|
125
|
+
n=n,
|
|
126
|
+
r=r,
|
|
127
|
+
p=p,
|
|
128
|
+
# scrypt needs 128*r*n bytes; OpenSSL's 32 MiB default refuses N=2^17.
|
|
129
|
+
maxmem=2 * 128 * r * n * p + 1024 * 1024,
|
|
94
130
|
dklen=64,
|
|
95
131
|
)
|
|
96
132
|
if kdf == _KDF_PBKDF2:
|
|
@@ -98,23 +134,23 @@ def _derive_key_material(passphrase, salt, kdf):
|
|
|
98
134
|
"sha256",
|
|
99
135
|
passphrase,
|
|
100
136
|
salt,
|
|
101
|
-
|
|
137
|
+
params["iterations"],
|
|
102
138
|
dklen=64,
|
|
103
139
|
)
|
|
104
140
|
raise CdxError("Unsupported bundle key derivation function.")
|
|
105
141
|
|
|
106
142
|
|
|
107
|
-
def _derive_keys(passphrase, salt, kdf=None):
|
|
143
|
+
def _derive_keys(passphrase, salt, kdf=None, params=None):
|
|
108
144
|
if not passphrase:
|
|
109
145
|
raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
|
|
110
146
|
if isinstance(passphrase, str):
|
|
111
147
|
passphrase = passphrase.encode("utf-8")
|
|
112
|
-
key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf())
|
|
148
|
+
key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf(), params)
|
|
113
149
|
return key_material[:32], key_material[32:]
|
|
114
150
|
|
|
115
151
|
|
|
116
|
-
def _derive_aead_key(passphrase, salt, kdf=None):
|
|
117
|
-
enc_key, _mac_key = _derive_keys(passphrase, salt, kdf)
|
|
152
|
+
def _derive_aead_key(passphrase, salt, kdf=None, params=None):
|
|
153
|
+
enc_key, _mac_key = _derive_keys(passphrase, salt, kdf, params)
|
|
118
154
|
return enc_key
|
|
119
155
|
|
|
120
156
|
|
|
@@ -152,11 +188,13 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
|
|
|
152
188
|
salt = os.urandom(_SALT_BYTES)
|
|
153
189
|
nonce = os.urandom(_AEAD_NONCE_BYTES)
|
|
154
190
|
kdf = _default_kdf()
|
|
155
|
-
|
|
191
|
+
params = _default_params(kdf)
|
|
192
|
+
aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt, kdf, params))
|
|
156
193
|
ciphertext = aesgcm.encrypt(nonce, payload_bytes, None)
|
|
157
194
|
wrapper.update({
|
|
158
195
|
"encryption": _AEAD_ALGORITHM,
|
|
159
196
|
"kdf": kdf,
|
|
197
|
+
"kdf_params": params,
|
|
160
198
|
"salt": _b64_encode(salt),
|
|
161
199
|
"nonce": _b64_encode(nonce),
|
|
162
200
|
"payload": _b64_encode(ciphertext),
|
|
@@ -186,9 +224,12 @@ def decode_bundle(data, passphrase=None):
|
|
|
186
224
|
payload_bytes = None
|
|
187
225
|
failure = None
|
|
188
226
|
for kdf in kdf_candidates:
|
|
227
|
+
params = _bundle_kdf_params(wrapper, kdf)
|
|
228
|
+
if params is None:
|
|
229
|
+
continue
|
|
189
230
|
# Derivation errors are about the bundle, not the passphrase, so
|
|
190
231
|
# they must escape rather than be retried as a bad secret.
|
|
191
|
-
cipher = aead(_derive_aead_key(passphrase, salt, kdf))
|
|
232
|
+
cipher = aead(_derive_aead_key(passphrase, salt, kdf, params))
|
|
192
233
|
try:
|
|
193
234
|
payload_bytes = cipher.decrypt(nonce, ciphertext, None)
|
|
194
235
|
break
|
|
@@ -200,7 +241,10 @@ def decode_bundle(data, passphrase=None):
|
|
|
200
241
|
expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
|
|
201
242
|
payload_bytes = None
|
|
202
243
|
for kdf in kdf_candidates:
|
|
203
|
-
|
|
244
|
+
params = _bundle_kdf_params(wrapper, kdf)
|
|
245
|
+
if params is None:
|
|
246
|
+
continue
|
|
247
|
+
enc_key, mac_key = _derive_keys(passphrase, salt, kdf, params)
|
|
204
248
|
actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
205
249
|
if hmac.compare_digest(actual_mac, expected_mac):
|
|
206
250
|
payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
|
package/src/claude_usage.py
CHANGED
|
@@ -214,7 +214,7 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
|
|
|
214
214
|
command,
|
|
215
215
|
env=run_env,
|
|
216
216
|
capture_output=True,
|
|
217
|
-
text=True,
|
|
217
|
+
text=True, encoding="utf-8", errors="replace",
|
|
218
218
|
timeout=CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS,
|
|
219
219
|
check=False,
|
|
220
220
|
)
|
package/src/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ import sys
|
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
|
|
8
8
|
from .agent_notify import handle_notify
|
|
9
|
+
from .cli_args import SET_USAGE
|
|
9
10
|
from .cli_commands import (
|
|
10
11
|
API_SCHEMA_VERSION,
|
|
11
12
|
STATUS_USAGE,
|
|
@@ -247,7 +248,7 @@ def _print_help(use_color=False):
|
|
|
247
248
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
248
249
|
f" {_style('cdx configs [--json]', '36', use_color)}",
|
|
249
250
|
f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
|
|
250
|
-
f" {_style('
|
|
251
|
+
f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
|
|
251
252
|
f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--all) [--json]', '36', use_color)}",
|
|
252
253
|
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
253
254
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
@@ -533,7 +534,6 @@ def main(argv, options=None):
|
|
|
533
534
|
stderr = options.get("stderr", sys.stderr)
|
|
534
535
|
stdin_is_tty = options.get("stdin", {}).get("isTTY", hasattr(sys.stdin, "isatty") and sys.stdin.isatty())
|
|
535
536
|
use_color = _should_use_color(env, stdout)
|
|
536
|
-
service = options.get("service") or create_session_service({"env": env})
|
|
537
537
|
spawn = options.get("spawn")
|
|
538
538
|
spawn_sync = options.get("spawn_sync")
|
|
539
539
|
refresh_fn = options.get("refreshClaudeSessionStatus")
|
|
@@ -559,6 +559,10 @@ def main(argv, options=None):
|
|
|
559
559
|
out(f"{_print_version()}\n")
|
|
560
560
|
return 0
|
|
561
561
|
|
|
562
|
+
# Built after --help/--version: those must answer even where the home
|
|
563
|
+
# directory cannot be resolved (a service or task with a minimal environment).
|
|
564
|
+
service = options.get("service") or create_session_service({"env": env})
|
|
565
|
+
|
|
562
566
|
if argv == ["--json"]:
|
|
563
567
|
rows = service["format_list_rows"]()
|
|
564
568
|
notices = _get_update_notices(service, env, options)
|
package/src/cli_args.py
CHANGED
|
@@ -16,6 +16,7 @@ from .config import (
|
|
|
16
16
|
PROVIDERS,
|
|
17
17
|
REASONING_EFFORT_VALUES,
|
|
18
18
|
normalize_permission,
|
|
19
|
+
parse_on_off,
|
|
19
20
|
)
|
|
20
21
|
from .errors import CdxArgumentError, CdxError
|
|
21
22
|
from .provider_runtime import _normalize_reasoning_effort
|
|
@@ -32,8 +33,8 @@ CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text..
|
|
|
32
33
|
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
33
34
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--source-conversation ID | --source-native-transcript PATH | --source-transcript PATH] [--json]"
|
|
34
35
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
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
|
-
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]"
|
|
36
|
+
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] [--notify on|off] [--notify-preview on|off] [--scrub-env on|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]"
|
|
37
|
+
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--reasoning-effort|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--scrub-env|--model|--fallback-model|--budget|--extra-args|--priority|--all) [--json]"
|
|
37
38
|
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
38
39
|
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
39
40
|
CONFIGS_USAGE = "Usage: cdx configs [--json]"
|
|
@@ -213,13 +214,12 @@ def _parse_toggle_args(args, usage):
|
|
|
213
214
|
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
214
215
|
|
|
215
216
|
|
|
216
|
-
def _parse_fast_value(value):
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
raise CdxError(SET_USAGE)
|
|
217
|
+
def _parse_fast_value(value, label="fast"):
|
|
218
|
+
return parse_on_off(value, label)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _on_off(label):
|
|
222
|
+
return lambda value: parse_on_off(value, label)
|
|
223
223
|
|
|
224
224
|
|
|
225
225
|
def _parse_priority_value(value):
|
|
@@ -248,10 +248,11 @@ def _parse_set_args(args):
|
|
|
248
248
|
"--power": {"key": "power", "type": "str", "default": None},
|
|
249
249
|
"--permission": {"key": "permission", "type": "str", "default": None},
|
|
250
250
|
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
251
|
-
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform":
|
|
252
|
-
"--logics": {"key": "logics", "type": "str", "default": None, "transform":
|
|
253
|
-
"--notify": {"key": "notify", "type": "str", "default": None, "transform":
|
|
254
|
-
"--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform":
|
|
251
|
+
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _on_off("rtk")},
|
|
252
|
+
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _on_off("logics")},
|
|
253
|
+
"--notify": {"key": "notify", "type": "str", "default": None, "transform": _on_off("notify")},
|
|
254
|
+
"--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _on_off("notify preview")},
|
|
255
|
+
"--scrub-env": {"key": "scrub_env", "type": "str", "default": None, "transform": _on_off("scrub env")},
|
|
255
256
|
"--model": {"key": "model", "type": "str", "default": None},
|
|
256
257
|
"--fallback-model": {"key": "fallback_model", "type": "str", "default": None},
|
|
257
258
|
"--budget": {"key": "budget", "type": "str", "default": None, "transform": _parse_budget_value},
|
|
@@ -273,7 +274,7 @@ def _parse_set_args(args):
|
|
|
273
274
|
raise CdxError(SET_USAGE)
|
|
274
275
|
settings = {
|
|
275
276
|
key: parsed[key]
|
|
276
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
277
|
+
for key in ("power", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env", "model", "fallback_model", "budget", "extra_args", "priority")
|
|
277
278
|
if parsed[key] is not None
|
|
278
279
|
}
|
|
279
280
|
if not settings:
|
|
@@ -297,6 +298,7 @@ def _parse_unset_args(args):
|
|
|
297
298
|
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
298
299
|
"--notify": {"key": "notify", "type": "bool", "default": False},
|
|
299
300
|
"--notify-preview": {"key": "notify_preview", "type": "bool", "default": False},
|
|
301
|
+
"--scrub-env": {"key": "scrub_env", "type": "bool", "default": False},
|
|
300
302
|
"--model": {"key": "model", "type": "bool", "default": False},
|
|
301
303
|
"--fallback-model": {"key": "fallback_model", "type": "bool", "default": False},
|
|
302
304
|
"--budget": {"key": "budget", "type": "bool", "default": False},
|
|
@@ -318,7 +320,7 @@ def _parse_unset_args(args):
|
|
|
318
320
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
319
321
|
raise CdxError(UNSET_USAGE)
|
|
320
322
|
_UNSET_KEYS = (
|
|
321
|
-
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
323
|
+
"power", "reasoning_effort", "permission", "fast", "rtk", "logics", "notify", "notify_preview", "scrub_env",
|
|
322
324
|
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
323
325
|
)
|
|
324
326
|
keys = list(_UNSET_KEYS) if parsed["all"] else [key for key in _UNSET_KEYS if parsed[key]]
|
|
@@ -510,8 +512,14 @@ def cdx_schema():
|
|
|
510
512
|
"provider_start_failed",
|
|
511
513
|
"provider_failed",
|
|
512
514
|
"provider_timeout",
|
|
515
|
+
"run_cancelled",
|
|
516
|
+
"invalid_request",
|
|
513
517
|
"cdx_error",
|
|
514
518
|
],
|
|
519
|
+
# The error code on a run record the stale sweep closed.
|
|
520
|
+
"run_record": [
|
|
521
|
+
"stale_process",
|
|
522
|
+
],
|
|
515
523
|
"run_lookup": [
|
|
516
524
|
"run_not_found",
|
|
517
525
|
"run_output_unavailable",
|
|
@@ -522,6 +530,26 @@ def cdx_schema():
|
|
|
522
530
|
"network_disabled_by_permission",
|
|
523
531
|
"session_selected_without_status",
|
|
524
532
|
"limit_ignored_with_since",
|
|
533
|
+
"claude_auth_probe_failed",
|
|
534
|
+
"claude_refresh_failed",
|
|
535
|
+
"disk_cleanup_available",
|
|
536
|
+
"update_available",
|
|
537
|
+
"logics_manager_update_available",
|
|
538
|
+
"update_version_check_failed",
|
|
539
|
+
"update_version_mismatch",
|
|
540
|
+
"logics_manager_missing",
|
|
541
|
+
"logics_viewer_failed",
|
|
542
|
+
"logics_viewer_root_invalid",
|
|
543
|
+
"tray_autostart_unsupported",
|
|
544
|
+
"tray_companion_missing",
|
|
545
|
+
"tray_companion_not_aligned",
|
|
546
|
+
"tray_companion_not_available",
|
|
547
|
+
"tray_companion_not_installed",
|
|
548
|
+
"tray_companion_restart_pending",
|
|
549
|
+
"tray_companion_updated",
|
|
550
|
+
"tray_plugin_action_unknown",
|
|
551
|
+
"tray_plugin_unknown",
|
|
552
|
+
"tray_terminal_invalid",
|
|
525
553
|
],
|
|
526
554
|
}
|
|
527
555
|
|
package/src/cli_helpers.py
CHANGED
|
@@ -303,6 +303,7 @@ def _format_launch_config(session, use_color=False):
|
|
|
303
303
|
("logics", "Logics"),
|
|
304
304
|
("notify", "Agent alerts"),
|
|
305
305
|
("notify_preview", "Response preview"),
|
|
306
|
+
("scrub_env", "Scrub agent env"),
|
|
306
307
|
("model", "Model"),
|
|
307
308
|
("fallback_model", "Fallback"),
|
|
308
309
|
("budget", "Budget"),
|
|
@@ -329,14 +330,14 @@ def _format_launch_settings_hint(name="<name>"):
|
|
|
329
330
|
)
|
|
330
331
|
|
|
331
332
|
def _format_launch_setting_value(launch, key, use_color=False):
|
|
332
|
-
if key in ("fast", "rtk", "logics", "notify", "notify_preview"):
|
|
333
|
+
if key in ("fast", "rtk", "logics", "notify", "notify_preview", "scrub_env"):
|
|
333
334
|
if launch.get(key) is True:
|
|
334
335
|
return _style("on", "32", use_color)
|
|
335
336
|
if launch.get(key) is False:
|
|
336
337
|
return _style("off", "2", use_color)
|
|
337
338
|
if key == "logics":
|
|
338
339
|
return _dim("auto", use_color)
|
|
339
|
-
if key in ("notify", "notify_preview"):
|
|
340
|
+
if key in ("notify", "notify_preview", "scrub_env"):
|
|
340
341
|
return _style("off", "2", use_color)
|
|
341
342
|
return _dim("default", use_color)
|
|
342
343
|
value = launch.get(key)
|
package/src/codex_usage.py
CHANGED
|
@@ -247,7 +247,7 @@ def _request_codex_app_server(auth_home, method, params, failure_reason, timeout
|
|
|
247
247
|
stdout=subprocess.PIPE,
|
|
248
248
|
stderr=subprocess.DEVNULL,
|
|
249
249
|
env=env,
|
|
250
|
-
text=True,
|
|
250
|
+
text=True, encoding="utf-8", errors="replace",
|
|
251
251
|
bufsize=1,
|
|
252
252
|
)
|
|
253
253
|
thread = threading.Thread(target=_reader_thread, args=(process.stdout, output), daemon=True)
|
package/src/commands/runs.py
CHANGED
|
@@ -265,7 +265,7 @@ def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
|
|
|
265
265
|
# Surface as a CdxError so handle_run's own handler marks the already
|
|
266
266
|
# registered run failed, instead of letting a raw OSError escape as an
|
|
267
267
|
# unhandled traceback on a --json call.
|
|
268
|
-
raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
|
|
268
|
+
raise CdxError(f"Failed to start detached cdx run: {error}", 126, code="provider_start_failed") from error
|
|
269
269
|
return {
|
|
270
270
|
"pid": getattr(child, "pid", None),
|
|
271
271
|
"launch_log_path": launch_log_path,
|
|
@@ -526,7 +526,7 @@ def handle_run(rest, ctx):
|
|
|
526
526
|
if not session:
|
|
527
527
|
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
528
528
|
if session.get("enabled", True) is False:
|
|
529
|
-
raise CdxError(f"Session is disabled: {parsed['name']}")
|
|
529
|
+
raise CdxError(f"Session is disabled: {parsed['name']}", code="session_disabled")
|
|
530
530
|
else:
|
|
531
531
|
selected = _select_headless_session(
|
|
532
532
|
ctx,
|
|
@@ -552,7 +552,7 @@ def handle_run(rest, ctx):
|
|
|
552
552
|
|
|
553
553
|
cwd = os.path.abspath(parsed["cwd"])
|
|
554
554
|
if not os.path.isdir(cwd):
|
|
555
|
-
raise CdxError(f"Invalid cwd: {parsed['cwd']}")
|
|
555
|
+
raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
|
|
556
556
|
prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
|
|
557
557
|
_consume_detached_prompt_file(parsed, detached_run_id)
|
|
558
558
|
launch_updates = {}
|
package/src/config.py
CHANGED
|
@@ -81,4 +81,20 @@ def normalize_permission(value):
|
|
|
81
81
|
def get_cdx_home(env=None):
|
|
82
82
|
if env is None:
|
|
83
83
|
env = os.environ
|
|
84
|
-
|
|
84
|
+
# `or`, not a get() default: the default is evaluated eagerly, and
|
|
85
|
+
# Path.home() raises on Windows when USERPROFILE is missing.
|
|
86
|
+
return env.get("CDX_HOME") or str(Path.home() / ".cdx")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_on_off(value, label):
|
|
90
|
+
"""Shared by `cdx set` flags and stored launch settings."""
|
|
91
|
+
from .errors import CdxError
|
|
92
|
+
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return value
|
|
95
|
+
text = str(value).strip().lower()
|
|
96
|
+
if text in ("on", "true", "1", "yes"):
|
|
97
|
+
return True
|
|
98
|
+
if text in ("off", "false", "0", "no"):
|
|
99
|
+
return False
|
|
100
|
+
raise CdxError(f"Unsupported {label} value: {value} (on|off)")
|
package/src/context_store.py
CHANGED
|
@@ -90,7 +90,11 @@ def read_context_path(path):
|
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def write_context(base_dir, content, cwd=None):
|
|
93
|
-
|
|
93
|
+
# Same lock as append: otherwise an append that read the old text can land
|
|
94
|
+
# after this write and resurrect it, or this write can drop the append.
|
|
95
|
+
path = get_context_path(base_dir, cwd)
|
|
96
|
+
with _append_lock(path), _registry_lock(path, timeout_seconds=10, label="memory"):
|
|
97
|
+
return write_context_path(path, content)
|
|
94
98
|
|
|
95
99
|
|
|
96
100
|
def write_context_path(path, content):
|
|
@@ -155,11 +159,12 @@ def clear_context(base_dir, cwd=None):
|
|
|
155
159
|
|
|
156
160
|
|
|
157
161
|
def clear_context_path(path):
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
with _append_lock(path), _registry_lock(path, timeout_seconds=10, label="memory"):
|
|
163
|
+
try:
|
|
164
|
+
os.remove(path)
|
|
165
|
+
removed = True
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
removed = False
|
|
163
168
|
return {"path": path, "removed": removed}
|
|
164
169
|
|
|
165
170
|
|
package/src/errors.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
class CdxError(Exception):
|
|
2
|
-
def __init__(self, message, exit_code=1):
|
|
2
|
+
def __init__(self, message, exit_code=1, *, code=None):
|
|
3
3
|
super().__init__(message)
|
|
4
4
|
self.exit_code = exit_code
|
|
5
|
+
# Machine-readable code for `cdx run --json`; when absent, callers fall
|
|
6
|
+
# back to classifying the message (run_command.run_cdx_error_code).
|
|
7
|
+
if code is not None:
|
|
8
|
+
self.code = code
|
|
5
9
|
|
|
6
10
|
|
|
7
11
|
class CdxArgumentError(CdxError):
|
package/src/health.py
CHANGED
|
@@ -162,7 +162,7 @@ def _run_version_command(command, env, spawn_sync=None):
|
|
|
162
162
|
[command, "--version"],
|
|
163
163
|
env=env,
|
|
164
164
|
capture_output=True,
|
|
165
|
-
text=True,
|
|
165
|
+
text=True, encoding="utf-8", errors="replace",
|
|
166
166
|
timeout=5,
|
|
167
167
|
check=False,
|
|
168
168
|
)
|
|
@@ -228,7 +228,7 @@ def _provider_help_text(command, env, spawn_sync=None):
|
|
|
228
228
|
text = f"{getattr(result, 'stdout', '') or ''}\n{getattr(result, 'stderr', '') or ''}"
|
|
229
229
|
else:
|
|
230
230
|
completed = subprocess.run(
|
|
231
|
-
[command, "--help"], env=env, capture_output=True, text=True, timeout=10, check=False,
|
|
231
|
+
[command, "--help"], env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, check=False,
|
|
232
232
|
)
|
|
233
233
|
text = f"{completed.stdout or ''}\n{completed.stderr or ''}"
|
|
234
234
|
except (OSError, subprocess.TimeoutExpired):
|
package/src/notify.py
CHANGED
|
@@ -215,8 +215,13 @@ def windows_aumid(env=None):
|
|
|
215
215
|
return _POWERSHELL_AUMID
|
|
216
216
|
|
|
217
217
|
|
|
218
|
+
# PowerShell also ends a single-quoted string on the typographic quotes
|
|
219
|
+
# U+2018..U+201B, so each must be doubled like ASCII ' (French "l’agent").
|
|
220
|
+
_POWERSHELL_QUOTES = "'\u2018\u2019\u201a\u201b"
|
|
221
|
+
|
|
222
|
+
|
|
218
223
|
def _escape_powershell(value):
|
|
219
|
-
return str(value)
|
|
224
|
+
return "".join(ch * 2 if ch in _POWERSHELL_QUOTES else ch for ch in str(value))
|
|
220
225
|
|
|
221
226
|
|
|
222
227
|
def _escape_applescript(value):
|
|
@@ -370,23 +375,23 @@ def _schedule_linux(parsed, event, argv, spawn_sync, env):
|
|
|
370
375
|
def _schedule_windows_task(parsed, event, argv, spawn_sync, env):
|
|
371
376
|
name = _schedule_id("cdx-manager-notify", parsed, event)
|
|
372
377
|
target = datetime.fromtimestamp(event["target_timestamp"]).astimezone()
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
"
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
|
|
385
|
-
"
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
"
|
|
389
|
-
|
|
378
|
+
# Not `schtasks /SD`: it reads the date in the user's locale format, so
|
|
379
|
+
# mm/dd/yyyy is refused on a dd/MM/yyyy (French) Windows. An invariant
|
|
380
|
+
# ISO date through Register-ScheduledTask works on every locale.
|
|
381
|
+
start = target.strftime("%Y-%m-%dT%H:%M")
|
|
382
|
+
end = (target + timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
|
|
383
|
+
arguments = subprocess.list2cmdline([str(part) for part in argv[1:]])
|
|
384
|
+
script = (
|
|
385
|
+
"$c = [Globalization.CultureInfo]::InvariantCulture; "
|
|
386
|
+
f"$t = New-ScheduledTaskTrigger -Once -At ([datetime]::ParseExact('{start}', 'yyyy-MM-ddTHH:mm', $c)); "
|
|
387
|
+
f"$t.EndBoundary = ([datetime]::ParseExact('{end}', 'yyyy-MM-ddTHH:mm', $c)).ToString('s'); "
|
|
388
|
+
f"$a = New-ScheduledTaskAction -Execute '{_escape_powershell(argv[0])}' -Argument '{_escape_powershell(arguments)}'; "
|
|
389
|
+
"$s = New-ScheduledTaskSettingsSet -DeleteExpiredTaskAfter (New-TimeSpan -Seconds 1); "
|
|
390
|
+
f"Register-ScheduledTask -TaskName '{_escape_powershell(name)}' -Action $a -Trigger $t -Settings $s -Force | Out-Null"
|
|
391
|
+
)
|
|
392
|
+
result = _run_scheduler_command(
|
|
393
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", script], spawn_sync, env,
|
|
394
|
+
)
|
|
390
395
|
if not result["ok"]:
|
|
391
396
|
raise CdxError(f"Failed to schedule notification with Task Scheduler: {result['error']}")
|
|
392
397
|
return {"scheduled": True, "existing": False, "backend": "schtasks", "id": name}
|
package/src/provider_runtime.py
CHANGED
|
@@ -419,11 +419,11 @@ def _normalize_reasoning_effort(reasoning_effort=None, power=None, usage="Unsupp
|
|
|
419
419
|
if alias == "":
|
|
420
420
|
raise CdxError(usage)
|
|
421
421
|
if effort and effort not in REASONING_EFFORT_VALUES:
|
|
422
|
-
raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}")
|
|
422
|
+
raise CdxError(f"Unsupported reasoning effort: {reasoning_effort}", code="invalid_reasoning_effort")
|
|
423
423
|
if alias and alias not in REASONING_EFFORT_VALUES:
|
|
424
424
|
raise CdxError(f"Unsupported power: {power}")
|
|
425
425
|
if effort and alias and effort != alias:
|
|
426
|
-
raise CdxError("--reasoning-effort and --power must match when both are provided.")
|
|
426
|
+
raise CdxError("--reasoning-effort and --power must match when both are provided.", code="invalid_reasoning_effort")
|
|
427
427
|
resolved = effort or alias
|
|
428
428
|
if not resolved:
|
|
429
429
|
return {}
|
|
@@ -573,6 +573,19 @@ def _rtk_enabled(session):
|
|
|
573
573
|
return (session.get("launch") or {}).get("rtk") is True
|
|
574
574
|
|
|
575
575
|
|
|
576
|
+
# Claude Code passes credentials (including the setup-token cdx injects) to
|
|
577
|
+
# the Bash commands, hooks and MCP servers it starts unless this is set. Opt-in:
|
|
578
|
+
# it also strips other secrets an agent may need and, on Linux, isolates Bash
|
|
579
|
+
# from host processes (`ps`, `kill`).
|
|
580
|
+
CLAUDE_SCRUB_ENV = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB"
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _scrub_env(session):
|
|
584
|
+
if session.get("provider") == PROVIDER_CLAUDE and (session.get("launch") or {}).get("scrub_env") is True:
|
|
585
|
+
return {CLAUDE_SCRUB_ENV: "1"}
|
|
586
|
+
return {}
|
|
587
|
+
|
|
588
|
+
|
|
576
589
|
def _logics_manager_available(path=None):
|
|
577
590
|
return shutil.which("logics-manager", path=path) is not None
|
|
578
591
|
|
|
@@ -662,6 +675,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
662
675
|
for key, value in _shared_tool_cache_overrides(env).items():
|
|
663
676
|
env.setdefault(key, value)
|
|
664
677
|
env.update(launch_notify_env(session, notifications_enabled(session)))
|
|
678
|
+
env.update(_scrub_env(session))
|
|
665
679
|
preference_args = _launch_preference_args(session, env=env)
|
|
666
680
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
667
681
|
launch = session.get("launch") or {}
|
|
@@ -915,6 +929,7 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
915
929
|
# Headless runs read the same home, and so the same hooks, as an interactive
|
|
916
930
|
# launch. Their caller already learns of completion from the return value.
|
|
917
931
|
env.update(launch_notify_env(session, enabled=False))
|
|
932
|
+
env.update(_scrub_env(session))
|
|
918
933
|
preference_args = _launch_preference_args(session, env=env)
|
|
919
934
|
launch = session.get("launch") or {}
|
|
920
935
|
power = _launch_power(session)
|
|
@@ -1056,6 +1071,32 @@ def _signal_child_group(child, sig):
|
|
|
1056
1071
|
return False
|
|
1057
1072
|
|
|
1058
1073
|
|
|
1074
|
+
def _raise_keyboard_interrupt(_signum, _frame):
|
|
1075
|
+
raise KeyboardInterrupt
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _route_termination_to_interrupt():
|
|
1079
|
+
"""Make SIGTERM and SIGHUP raise KeyboardInterrupt; returns what to restore."""
|
|
1080
|
+
previous = {}
|
|
1081
|
+
for name in ("SIGTERM", "SIGHUP"):
|
|
1082
|
+
sig = getattr(signal, name, None)
|
|
1083
|
+
if sig is None:
|
|
1084
|
+
continue
|
|
1085
|
+
try:
|
|
1086
|
+
previous[sig] = signal.signal(sig, _raise_keyboard_interrupt)
|
|
1087
|
+
except (ValueError, OSError): # not the main thread, or not settable here
|
|
1088
|
+
pass
|
|
1089
|
+
return previous
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _restore_signal_handlers(previous):
|
|
1093
|
+
for sig, handler in previous.items():
|
|
1094
|
+
try:
|
|
1095
|
+
signal.signal(sig, handler)
|
|
1096
|
+
except (ValueError, OSError):
|
|
1097
|
+
pass
|
|
1098
|
+
|
|
1099
|
+
|
|
1059
1100
|
def _terminate_child_tree(child):
|
|
1060
1101
|
if not _signal_child_group(child, signal.SIGTERM):
|
|
1061
1102
|
try:
|
|
@@ -1111,14 +1152,18 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1111
1152
|
)
|
|
1112
1153
|
except FileNotFoundError as error:
|
|
1113
1154
|
_combine_headless_transcript(paths)
|
|
1114
|
-
cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127)
|
|
1155
|
+
cdx_error = CdxError(f"{spec['label']} CLI not found on PATH: {spec['command']}", 127, code="provider_cli_not_found")
|
|
1115
1156
|
cdx_error.run_info = _headless_run_info(paths, spec, start_time, 127)
|
|
1116
1157
|
raise cdx_error from error
|
|
1117
1158
|
except OSError as error:
|
|
1118
1159
|
_combine_headless_transcript(paths)
|
|
1119
|
-
cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126)
|
|
1160
|
+
cdx_error = CdxError(f"Failed to start {spec['label']}: {error}", 126, code="provider_start_failed")
|
|
1120
1161
|
cdx_error.run_info = _headless_run_info(paths, spec, start_time, 126)
|
|
1121
1162
|
raise cdx_error from error
|
|
1163
|
+
# SIGTERM/SIGHUP (kill, systemctl stop, a closed SSH session) unwind
|
|
1164
|
+
# like Ctrl+C: the provider's own process group would otherwise outlive
|
|
1165
|
+
# this supervisor and keep its run "running" forever.
|
|
1166
|
+
previous_handlers = _route_termination_to_interrupt()
|
|
1122
1167
|
try:
|
|
1123
1168
|
if timeout_seconds is None:
|
|
1124
1169
|
child.wait()
|
|
@@ -1136,6 +1181,8 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1136
1181
|
# the run still gets a terminal record.
|
|
1137
1182
|
cancelled = True
|
|
1138
1183
|
_terminate_child_tree(child)
|
|
1184
|
+
finally:
|
|
1185
|
+
_restore_signal_handlers(previous_handlers)
|
|
1139
1186
|
|
|
1140
1187
|
if cancelled:
|
|
1141
1188
|
_combine_headless_transcript(paths)
|
|
@@ -1283,7 +1330,7 @@ def _probe_provider_auth_status(session, spawn_sync=None, env_override=None, tru
|
|
|
1283
1330
|
result = subprocess.run(
|
|
1284
1331
|
[command] + spec["args"],
|
|
1285
1332
|
env=spec["env"],
|
|
1286
|
-
capture_output=True, text=True,
|
|
1333
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
1287
1334
|
timeout=AUTH_PROBE_TIMEOUT_SECONDS,
|
|
1288
1335
|
)
|
|
1289
1336
|
output = (result.stdout or "") + (result.stderr or "")
|
package/src/session_service.py
CHANGED
|
@@ -26,7 +26,6 @@ the re-exports as unused and callers importing through this module break.
|
|
|
26
26
|
`test_session_service_facade_still_exposes_every_name_its_callers_import`
|
|
27
27
|
guards that.
|
|
28
28
|
"""
|
|
29
|
-
import json
|
|
30
29
|
import os
|
|
31
30
|
import shutil
|
|
32
31
|
import tempfile
|
|
@@ -49,8 +48,11 @@ from .config import (
|
|
|
49
48
|
normalize_permission,
|
|
50
49
|
split_extra_args,
|
|
51
50
|
)
|
|
51
|
+
from .config import (
|
|
52
|
+
parse_on_off as _parse_on_off,
|
|
53
|
+
)
|
|
52
54
|
from .errors import CdxError
|
|
53
|
-
from .fs_utils import
|
|
55
|
+
from .fs_utils import remove_tree
|
|
54
56
|
from .session_backup import ( # noqa: F401 (re-exported for callers and tests)
|
|
55
57
|
_FORCE_IMPORT_PRESERVED_PROFILE_PATHS,
|
|
56
58
|
_auth_bundle_paths,
|
|
@@ -145,18 +147,7 @@ def _seed_codex_auth_from_global(auth_home, env=None):
|
|
|
145
147
|
return True
|
|
146
148
|
|
|
147
149
|
|
|
148
|
-
_ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview"}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def _parse_on_off(value, label):
|
|
152
|
-
if isinstance(value, bool):
|
|
153
|
-
return value
|
|
154
|
-
text = str(value).strip().lower()
|
|
155
|
-
if text in ("on", "true", "1", "yes"):
|
|
156
|
-
return True
|
|
157
|
-
if text in ("off", "false", "0", "no"):
|
|
158
|
-
return False
|
|
159
|
-
raise CdxError(f"Unsupported {label} value: {value}")
|
|
150
|
+
_ON_OFF_SETTINGS = {"fast": "fast", "rtk": "rtk", "logics": "logics", "notify": "notify", "notify_preview": "notify preview", "scrub_env": "scrub env"}
|
|
160
151
|
|
|
161
152
|
|
|
162
153
|
def _has_control_chars(text):
|
|
@@ -175,7 +166,7 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
175
166
|
if "reasoning_effort" in settings and settings["reasoning_effort"] is not None:
|
|
176
167
|
effort = str(settings["reasoning_effort"]).strip().lower()
|
|
177
168
|
if effort not in LAUNCH_REASONING_EFFORT_VALUES:
|
|
178
|
-
raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}")
|
|
169
|
+
raise CdxError(f"Unsupported reasoning effort: {settings['reasoning_effort']}", code="invalid_reasoning_effort")
|
|
179
170
|
normalized["reasoning_effort"] = effort
|
|
180
171
|
if "permission" in settings and settings["permission"] is not None:
|
|
181
172
|
# Accepts the provider-native aliases (workspace-write, read-only,
|
|
@@ -260,24 +251,18 @@ def _parse_status_timeout_seconds(value):
|
|
|
260
251
|
|
|
261
252
|
|
|
262
253
|
def _ensure_claude_attribution_disabled(auth_home):
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
settings = {}
|
|
271
|
-
except (OSError, json.JSONDecodeError):
|
|
272
|
-
settings = {}
|
|
273
|
-
if not isinstance(settings, dict):
|
|
274
|
-
settings = {}
|
|
275
|
-
settings["includeCoAuthoredBy"] = False
|
|
276
|
-
try:
|
|
277
|
-
atomic_write(settings_path, json.dumps(settings, indent=2, sort_keys=True) + "\n", mode=0o644)
|
|
278
|
-
except OSError:
|
|
254
|
+
# Same reader/writer as the notification hooks: an unparseable file is the
|
|
255
|
+
# user's and is left alone, a symlink stays a symlink, the mode is kept.
|
|
256
|
+
from .agent_notify import _read_json, _write_json
|
|
257
|
+
|
|
258
|
+
settings_path = os.path.join(auth_home, ".claude", "settings.json")
|
|
259
|
+
settings = _read_json(settings_path)
|
|
260
|
+
if settings is None:
|
|
279
261
|
return False
|
|
280
|
-
|
|
262
|
+
if settings.get("includeCoAuthoredBy") is False:
|
|
263
|
+
return True
|
|
264
|
+
settings["includeCoAuthoredBy"] = False
|
|
265
|
+
return _write_json(settings_path, settings)
|
|
281
266
|
|
|
282
267
|
|
|
283
268
|
def _normalize_session_label(label):
|
|
@@ -334,7 +319,7 @@ def unset_launch_settings(store, name, keys):
|
|
|
334
319
|
raise CdxError("At least one launch setting is required.")
|
|
335
320
|
allowed = {
|
|
336
321
|
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify", "notify_preview",
|
|
337
|
-
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
322
|
+
"scrub_env", "model", "fallback_model", "budget", "extra_args", "priority",
|
|
338
323
|
}
|
|
339
324
|
unknown = [key for key in keys if key not in allowed]
|
|
340
325
|
if unknown:
|
|
@@ -868,7 +853,7 @@ def launch_session(store, name, resume=False):
|
|
|
868
853
|
if not session:
|
|
869
854
|
raise CdxError(f"Unknown session: {name}")
|
|
870
855
|
if session.get("enabled", True) is False:
|
|
871
|
-
raise CdxError(f"Session is disabled: {name}")
|
|
856
|
+
raise CdxError(f"Session is disabled: {name}", code="session_disabled")
|
|
872
857
|
# Reconcile the prior Claude conversation before a fresh launch replaces
|
|
873
858
|
# its id. This is a single exact transcript lookup, not a global stats scan.
|
|
874
859
|
backfill_interactive_usage(store, name=name, dry_run=False)
|
package/src/tray_events.py
CHANGED
|
@@ -24,6 +24,8 @@ import json
|
|
|
24
24
|
import os
|
|
25
25
|
import time
|
|
26
26
|
|
|
27
|
+
from .errors import CdxError
|
|
28
|
+
|
|
27
29
|
SCHEMA_VERSION = 1
|
|
28
30
|
# The tray heartbeats once per poll: 30s native, 60s across WSL. This window has
|
|
29
31
|
# to clear the slowest of those with room for a late tick, or a live tray reads
|
|
@@ -130,12 +132,13 @@ def publish(base_dir, title, message, kind="complete", now=None, event_id=None,
|
|
|
130
132
|
path = events_path(base_dir)
|
|
131
133
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
132
134
|
line = json.dumps(event, ensure_ascii=False) + "\n"
|
|
133
|
-
# O_APPEND
|
|
134
|
-
#
|
|
135
|
-
|
|
135
|
+
# O_APPEND keeps concurrent hooks apart; the lock keeps this append
|
|
136
|
+
# out of acknowledge()'s read-and-replace, which would otherwise
|
|
137
|
+
# overwrite it and lose the notification.
|
|
138
|
+
with _spool_lock(path), open(path, "a", encoding="utf-8") as handle:
|
|
136
139
|
handle.write(line)
|
|
137
140
|
return True
|
|
138
|
-
except OSError:
|
|
141
|
+
except (OSError, CdxError):
|
|
139
142
|
return False
|
|
140
143
|
|
|
141
144
|
|
|
@@ -171,6 +174,11 @@ def read_events(base_dir, include_acknowledged=False):
|
|
|
171
174
|
return events[-MAX_SPOOLED_EVENTS:]
|
|
172
175
|
|
|
173
176
|
|
|
177
|
+
# A hook must never stall an agent's turn on this lock; giving up only means
|
|
178
|
+
# the alert is delivered directly instead.
|
|
179
|
+
SPOOL_LOCK_TIMEOUT_SECONDS = 2
|
|
180
|
+
|
|
181
|
+
|
|
174
182
|
def acknowledge(base_dir, ids, now=None):
|
|
175
183
|
"""Mark events consumed, idempotently, and compact when it is worth it.
|
|
176
184
|
|
|
@@ -183,6 +191,17 @@ def acknowledge(base_dir, ids, now=None):
|
|
|
183
191
|
if not wanted:
|
|
184
192
|
return {"acknowledged": 0, "compacted": False}
|
|
185
193
|
|
|
194
|
+
with _spool_lock(events_path(base_dir)):
|
|
195
|
+
return _acknowledge_locked(base_dir, wanted, now)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _spool_lock(path):
|
|
199
|
+
from .run_registry import _registry_lock
|
|
200
|
+
|
|
201
|
+
return _registry_lock(path, timeout_seconds=SPOOL_LOCK_TIMEOUT_SECONDS, label="tray events")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _acknowledge_locked(base_dir, wanted, now):
|
|
186
205
|
try:
|
|
187
206
|
with open(events_path(base_dir), encoding="utf-8") as handle:
|
|
188
207
|
lines = handle.readlines()
|
package/src/tray_logics.py
CHANGED
|
@@ -88,7 +88,7 @@ def _status(env=None, runner=None, executable=None, directories=None):
|
|
|
88
88
|
try:
|
|
89
89
|
completed = (runner or subprocess.run)(
|
|
90
90
|
[executable, "status", "--format", "json"], capture_output=True,
|
|
91
|
-
text=True, timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
|
|
91
|
+
text=True, encoding="utf-8", errors="replace", timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
|
|
92
92
|
)
|
|
93
93
|
payload = json.loads(completed.stdout) if getattr(completed, "returncode", 1) == 0 else None
|
|
94
94
|
except (OSError, subprocess.SubprocessError, TypeError, ValueError):
|
package/src/update_all.py
CHANGED
|
@@ -33,7 +33,7 @@ def _which(command, env):
|
|
|
33
33
|
def _run(command, env, runner=None, timeout=15):
|
|
34
34
|
runner = runner or subprocess.run
|
|
35
35
|
try:
|
|
36
|
-
result = runner(command, env=env, capture_output=True, text=True, check=False, timeout=timeout)
|
|
36
|
+
result = runner(command, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, timeout=timeout)
|
|
37
37
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
38
38
|
return {"returncode": 1, "stdout": "", "stderr": str(error)}
|
|
39
39
|
if isinstance(result, dict):
|
package/src/update_check.py
CHANGED
package/src/update_manager.py
CHANGED
|
@@ -43,7 +43,7 @@ def _is_git_dirty(package_root):
|
|
|
43
43
|
result = subprocess.run(
|
|
44
44
|
["git", "-C", str(package_root), "status", "--porcelain"],
|
|
45
45
|
capture_output=True,
|
|
46
|
-
text=True,
|
|
46
|
+
text=True, encoding="utf-8", errors="replace",
|
|
47
47
|
check=False,
|
|
48
48
|
)
|
|
49
49
|
except FileNotFoundError as error:
|
|
@@ -216,7 +216,11 @@ def _version_check_env(env):
|
|
|
216
216
|
source = env or os.environ
|
|
217
217
|
allowed = {
|
|
218
218
|
key: source[key]
|
|
219
|
-
|
|
219
|
+
# The home variables let the child find its home directory: without
|
|
220
|
+
# USERPROFILE, Windows Python's Path.home() raises and `cdx -v` prints
|
|
221
|
+
# a traceback instead of the version.
|
|
222
|
+
for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR",
|
|
223
|
+
"HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "CDX_HOME")
|
|
220
224
|
if source.get(key)
|
|
221
225
|
}
|
|
222
226
|
return allowed
|
|
@@ -244,7 +248,7 @@ def verify_updated_command(target_version, runner=None, env=None):
|
|
|
244
248
|
env=_version_check_env(env),
|
|
245
249
|
check=False,
|
|
246
250
|
capture_output=True,
|
|
247
|
-
text=True,
|
|
251
|
+
text=True, encoding="utf-8", errors="replace",
|
|
248
252
|
)
|
|
249
253
|
except OSError as error:
|
|
250
254
|
return {
|