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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.21.6-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
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.5
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 |
@@ -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 %ERRORLEVEL%==0 (
98
- py -3 "%SCRIPT%" %*
99
- exit /b %ERRORLEVEL%
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.21.5",
3
+ "version": "0.21.6",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.21.5"
7
+ version = "0.21.6"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -486,7 +486,7 @@ def _subprocess_spawn_sync(command, args, options):
486
486
  [command, *args],
487
487
  env=options.get("env"),
488
488
  capture_output=True,
489
- text=True,
489
+ text=True, encoding="utf-8", errors="replace",
490
490
  timeout=60,
491
491
  )
492
492
 
@@ -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
- _SCRYPT_N = 2 ** 14
15
- _SCRYPT_R = 8
16
- _SCRYPT_P = 1
17
- _PBKDF2_ITERATIONS = 200000
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 _derive_key_material(passphrase, salt, kdf):
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=_SCRYPT_N,
92
- r=_SCRYPT_R,
93
- p=_SCRYPT_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
- _PBKDF2_ITERATIONS,
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
- aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt, kdf))
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
- enc_key, mac_key = _derive_keys(passphrase, salt, kdf)
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)
@@ -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('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] [--model MODEL] [--priority 0..100] [--json]', '36', use_color)}",
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
- text = str(value).strip().lower()
218
- if text in ("on", "true", "1", "yes"):
219
- return True
220
- if text in ("off", "false", "0", "no"):
221
- return False
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": _parse_fast_value},
252
- "--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
253
- "--notify": {"key": "notify", "type": "str", "default": None, "transform": _parse_fast_value},
254
- "--notify-preview": {"key": "notify_preview", "type": "str", "default": None, "transform": _parse_fast_value},
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
 
@@ -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)
@@ -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)
@@ -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
- return env.get("CDX_HOME", str(Path.home() / ".cdx"))
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)")
@@ -90,7 +90,11 @@ def read_context_path(path):
90
90
 
91
91
 
92
92
  def write_context(base_dir, content, cwd=None):
93
- return write_context_path(get_context_path(base_dir, cwd), content)
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
- try:
159
- os.remove(path)
160
- removed = True
161
- except FileNotFoundError:
162
- removed = False
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).replace("'", "''")
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
- command = subprocess.list2cmdline([str(part) for part in argv])
374
- result = _run_scheduler_command([
375
- "schtasks",
376
- "/Create",
377
- "/SC",
378
- "ONCE",
379
- "/TN",
380
- name,
381
- "/TR",
382
- command,
383
- "/ST",
384
- target.strftime("%H:%M"),
385
- "/SD",
386
- target.strftime("%m/%d/%Y"),
387
- "/F",
388
- "/Z",
389
- ], spawn_sync, env)
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}
@@ -107,7 +107,7 @@ def _default_spawn_sync(argv, env=None):
107
107
  completed = subprocess.run(
108
108
  argv,
109
109
  capture_output=True,
110
- text=True,
110
+ text=True, encoding="utf-8", errors="replace",
111
111
  timeout=_CAPABILITY_PROBE_TIMEOUT_SECONDS,
112
112
  env=env,
113
113
  check=False,
@@ -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 "")
@@ -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 atomic_write, remove_tree
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
- settings_dir = os.path.join(auth_home, ".claude")
264
- settings_path = os.path.join(settings_dir, "settings.json")
265
- try:
266
- os.makedirs(settings_dir, exist_ok=True)
267
- with open(settings_path, encoding="utf-8") as handle:
268
- settings = json.load(handle)
269
- except FileNotFoundError:
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
- return True
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)
@@ -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 makes this atomic against other hooks firing at the same
134
- # instant, which a read-modify-write of a JSON array would not be.
135
- with open(path, "a", encoding="utf-8") as handle:
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()
@@ -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):
@@ -143,7 +143,7 @@ def _installed_logics_manager_version(env=None, runner=None):
143
143
  result = runner(
144
144
  [executable, "--version"],
145
145
  capture_output=True,
146
- text=True,
146
+ text=True, encoding="utf-8", errors="replace",
147
147
  timeout=2,
148
148
  env=env,
149
149
  )
@@ -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
- for key in ("PATH", "PATHEXT", "SystemRoot", "COMSPEC", "WINDIR")
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 {