cdx-manager 0.9.14 → 0.9.16

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
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.9.14-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.9.16-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -7,12 +7,6 @@ const { spawnSync } = require("node:child_process");
7
7
 
8
8
  const PYTHON_VERSION_CHECK = "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)";
9
9
  const PYTHON_CACHE_PREFIX = path.join(os.tmpdir(), "cdx-manager-pycache");
10
- const SIGNAL_EXIT_CODES = {
11
- SIGHUP: 129,
12
- SIGINT: 130,
13
- SIGTERM: 143,
14
- };
15
-
16
10
  const WINDOWS_CANDIDATES = [
17
11
  { command: "py", args: ["-3"], label: "py -3" },
18
12
  { command: "python", args: [], label: "python" },
@@ -148,7 +142,10 @@ function runPython(args, options = {}) {
148
142
  }
149
143
 
150
144
  if (result.signal) {
151
- return SIGNAL_EXIT_CODES[result.signal] || 128;
145
+ // Conventional 128+signum so supervisors can tell SIGKILL (137) from
146
+ // SIGSEGV (139) instead of a flat 128.
147
+ const signum = os.constants.signals[result.signal];
148
+ return signum ? 128 + signum : 128;
152
149
  }
153
150
 
154
151
  return typeof result.status === "number" ? result.status : 1;
@@ -0,0 +1,39 @@
1
+ # CDX Manager 0.9.15
2
+
3
+ ## Highlights
4
+
5
+ - Hardened concurrent session, run, and file updates against lost or partial writes.
6
+ - Improved status accuracy when multiple Codex and Claude data sources disagree.
7
+ - Tightened credential handling, headless process cleanup, CLI parsing, and release gating.
8
+
9
+ ## Changes
10
+
11
+ ### Reliable state and status
12
+
13
+ Session and run read-modify-write operations now stay under a single lock, and context, settings, and bundle exports use atomic replacement. Merge imports preserve live local runtime state, malformed JSONL usage records no longer discard an entire run, and refresh tolerates late or failed per-session writes.
14
+
15
+ Status selection now ranks structured, attributed, and fresh records consistently, preserves compatible fields across equally trusted candidates, handles year-wrapped reset timestamps, and treats a zero credit balance as no remaining credit.
16
+
17
+ ### Safer auth and process handling
18
+
19
+ Credential files and directories are private from creation, and temporary Claude setup-token transcripts are removed on both success and failure.
20
+
21
+ Headless timeouts terminate the complete process group, while signal exits consistently return `128 + signum`.
22
+
23
+ ### CLI, updates, and release automation
24
+
25
+ Argument parsing now rejects conflicting target selectors and flags used as values, while subcommand help remains scoped to the subcommand. Headless selection no longer imposes an implicit reasoning-effort floor.
26
+
27
+ Failed Logics Manager version checks are no longer cached, missing updater return codes fail safely, non-epoch Claude reset headers no longer abort refresh, and publication now gates on the latest CI run for the release commit.
28
+
29
+ ## Validation
30
+
31
+ - `npm run release:validate`
32
+ - `npm run lint`
33
+ - `npm test`
34
+ - `logics-manager lint --require-status`
35
+ - `logics-manager audit`
36
+ - `git diff --check`
37
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
38
+ - `python -m build`
39
+ - `python -m twine check dist/*`
@@ -0,0 +1,19 @@
1
+ # CDX Manager 0.9.16
2
+
3
+ ## Highlights
4
+
5
+ - Clear error message when a provider CLI binary is broken instead of a raw Python traceback.
6
+
7
+ ## Changes
8
+
9
+ ### Friendlier auth probe failures
10
+
11
+ Launching a session whose provider CLI exists but cannot be executed (corrupted install, wrong architecture, or a script missing its shebang — e.g. `Exec format error` on `~/.local/bin/claude`) previously crashed with an unhandled `OSError` traceback. The auth probe now catches all `OSError` failures and reports an actionable message telling the user to reinstall the provider CLI, exiting with code 126.
12
+
13
+ ## Validation
14
+
15
+ - `npm run release:validate`
16
+ - `npm run lint`
17
+ - `npm test`
18
+ - `git diff --check`
19
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
@@ -120,6 +120,14 @@
120
120
  "v0.9.13": {
121
121
  "github_tarball_sha256": "4541dcecbfc12705f8e50e61ab1cb1f53dd6a209cc4a6d33ec3a4e533a3da3eb",
122
122
  "github_zip_sha256": "a8df11be1910a0562e0f97f69e9f35ee2fdf90ef50cb40689c85bf5ee5937027"
123
+ },
124
+ "v0.9.14": {
125
+ "github_tarball_sha256": "5469be5448eee56b5f0834d0c42d9ed3ca088158f08052bd8addc552635fe33d",
126
+ "github_zip_sha256": "7eb5650cb348e3ef2a082cd7fb39d7f4c170d4a095fe88719a7ebbfb7601db48"
127
+ },
128
+ "v0.9.15": {
129
+ "github_tarball_sha256": "08e6c5a3459fdd65d1f6085ca03a3614f07b4bf44c57a995a3ef47446aa70482",
130
+ "github_zip_sha256": "c8ac01f4a65e85b417f5eba5139868ab17b8f0952bf99f17f0a79f73ef0f36d6"
123
131
  }
124
132
  }
125
133
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.9.14",
3
+ "version": "0.9.16",
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.9.14"
7
+ version = "0.9.16"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,6 +40,8 @@ def read_bundle_meta(data):
40
40
  except (UnicodeDecodeError, json.JSONDecodeError) as error:
41
41
  raise CdxError("Invalid bundle format.") from error
42
42
 
43
+ if not isinstance(wrapper, dict):
44
+ raise CdxError("Invalid bundle format.")
43
45
  if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
44
46
  raise CdxError("Unsupported bundle schema version.")
45
47
  return wrapper
@@ -83,9 +83,16 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
83
83
  for t in threads:
84
84
  t.join(timeout=10)
85
85
 
86
+ # A worker that outlived its join can still insert into results/errors;
87
+ # snapshot both so late writes can't mutate what we iterate.
88
+ results = dict(results)
89
+ errors = list(errors)
90
+
91
+ recorded = []
86
92
  for name, usage in results.items():
87
93
  try:
88
94
  service["record_status"](name, usage)
89
- except CdxError:
95
+ recorded.append(name)
96
+ except (CdxError, OSError):
90
97
  errors.append({"session": name, "error": CdxError(f"Failed to record Claude status for {name}")})
91
- return {"refreshed": sorted(results), "errors": errors}
98
+ return {"refreshed": sorted(recorded), "errors": errors}
@@ -103,10 +103,41 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
103
103
 
104
104
 
105
105
  def _format_reset_date(unix_seconds):
106
- dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
106
+ try:
107
+ dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
108
+ except (TypeError, ValueError, OSError):
109
+ return None
107
110
  return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
108
111
 
109
112
 
113
+ def _parse_reset_epoch(value):
114
+ # The reset headers are unix seconds today, but an RFC3339 value must
115
+ # not abort the whole refresh.
116
+ if value in (None, ""):
117
+ return None
118
+ text = str(value).strip()
119
+ try:
120
+ return float(text)
121
+ except ValueError:
122
+ pass
123
+ try:
124
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
125
+ except ValueError:
126
+ return None
127
+ if parsed.tzinfo is None:
128
+ parsed = parsed.replace(tzinfo=timezone.utc)
129
+ return parsed.timestamp()
130
+
131
+
132
+ def _safe_float(value):
133
+ if value is None:
134
+ return None
135
+ try:
136
+ return float(value)
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+
110
141
  def _remaining_from_utilization(value):
111
142
  if value is None:
112
143
  return None
@@ -184,11 +215,11 @@ def fetch_claude_rate_limit_headers(access_token):
184
215
  if util_5h is None and util_7d is None:
185
216
  return None
186
217
 
187
- utilization_5h = float(util_5h) if util_5h is not None else None
188
- utilization_7d = float(util_7d) if util_7d is not None else None
218
+ utilization_5h = _safe_float(util_5h)
219
+ utilization_7d = _safe_float(util_7d)
189
220
 
190
- reset_5h_at = _format_reset_date(int(reset_5h)) if reset_5h else None
191
- reset_week_at = _format_reset_date(int(reset_7d)) if reset_7d else None
221
+ reset_5h_at = _format_reset_date(_parse_reset_epoch(reset_5h)) if reset_5h else None
222
+ reset_week_at = _format_reset_date(_parse_reset_epoch(reset_7d)) if reset_7d else None
192
223
  reset_at = reset_week_at or reset_5h_at
193
224
 
194
225
  return {
package/src/cli.py CHANGED
@@ -66,7 +66,7 @@ from .status_view import (
66
66
  )
67
67
  from .update_check import check_for_update, check_logics_manager_for_update
68
68
 
69
- VERSION = "0.9.14"
69
+ VERSION = "0.9.16"
70
70
 
71
71
  # Public surface: this module is a facade. Names below are imported above
72
72
  # purely to be re-exported (consumed by tests and external callers); listing
@@ -324,14 +324,15 @@ def main(argv, options=None):
324
324
  def err(text):
325
325
  stderr.write(text)
326
326
 
327
- # Flags
328
- if "--help" in argv or "-h" in argv:
327
+ # Flags — only when leading, so 'cdx history -h' reaches the subcommand
328
+ # instead of being hijacked as a malformed top-level help call.
329
+ if argv[:1] in (["--help"], ["-h"]):
329
330
  if len(argv) != 1:
330
331
  raise CdxError("Usage: cdx --help")
331
332
  out(f"{_print_help(use_color=use_color)}\n")
332
333
  return 0
333
334
 
334
- if "--version" in argv or "-v" in argv:
335
+ if argv[:1] in (["--version"], ["-v"]):
335
336
  if len(argv) != 1:
336
337
  raise CdxError("Usage: cdx --version")
337
338
  out(f"{_print_version()}\n")
package/src/cli_args.py CHANGED
@@ -55,7 +55,7 @@ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=
55
55
  parsed[spec["key"]] = True
56
56
  index += 1
57
57
  continue
58
- value, index = _read_option_value(args, index, usage)
58
+ value, index = _read_option_value(args, index, usage, schema=schema)
59
59
  parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
60
60
  continue
61
61
  if arg.startswith("--") and "=" in arg:
@@ -162,6 +162,8 @@ def _parse_set_args(args):
162
162
  raise CdxError(SET_USAGE)
163
163
  if parsed["names"] and parsed["provider"]:
164
164
  raise CdxError(SET_USAGE)
165
+ if parsed["sessions"] and parsed["provider"]:
166
+ raise CdxError(SET_USAGE)
165
167
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
166
168
  raise CdxError(SET_USAGE)
167
169
  settings = {
@@ -200,6 +202,8 @@ def _parse_unset_args(args):
200
202
  raise CdxError(UNSET_USAGE)
201
203
  if parsed["names"] and parsed["provider"]:
202
204
  raise CdxError(UNSET_USAGE)
205
+ if parsed["sessions"] and parsed["provider"]:
206
+ raise CdxError(UNSET_USAGE)
203
207
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
204
208
  raise CdxError(UNSET_USAGE)
205
209
  keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
@@ -501,10 +505,16 @@ def _parse_stats_args(args, now=None):
501
505
  }
502
506
 
503
507
 
504
- def _read_option_value(args, index, usage):
508
+ def _read_option_value(args, index, usage, schema=None):
505
509
  if index + 1 >= len(args):
506
510
  raise CdxError(usage)
507
- return args[index + 1], index + 2
511
+ value = args[index + 1]
512
+ # A known flag is never a value: '--model --json' is a missing value,
513
+ # not model='--json'. Literal flag-like values remain expressible as
514
+ # '--model=--json'.
515
+ if schema and value in schema:
516
+ raise CdxError(usage)
517
+ return value, index + 2
508
518
 
509
519
 
510
520
  def _parse_session_names(value):
@@ -79,6 +79,7 @@ from .context_store import (
79
79
  write_context,
80
80
  )
81
81
  from .errors import CdxError
82
+ from .fs_utils import atomic_write
82
83
  from .health import collect_health_report, format_health_report
83
84
  from .notify import (
84
85
  format_notify_event,
@@ -170,20 +171,19 @@ def _extract_claude_oauth_token(text):
170
171
 
171
172
  def _write_claude_oauth_token(auth_home, token):
172
173
  cred_dir = os.path.join(auth_home, "credentials")
173
- os.makedirs(cred_dir, exist_ok=True)
174
+ os.makedirs(cred_dir, mode=0o700, exist_ok=True)
175
+ try:
176
+ os.chmod(cred_dir, 0o700)
177
+ except OSError:
178
+ pass
174
179
  cred_path = os.path.join(cred_dir, "default.json")
175
180
  payload = {
176
181
  "version": "1.0",
177
182
  "type": "oauth_token",
178
183
  "access_token": token,
179
184
  }
180
- with open(cred_path, "w", encoding="utf-8") as handle:
181
- json.dump(payload, handle, indent=2)
182
- handle.write("\n")
183
- try:
184
- os.chmod(cred_path, 0o600)
185
- except OSError:
186
- pass
185
+ # atomic_write keeps the token 0o600 from creation — no umask window.
186
+ atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
187
187
  return cred_path
188
188
 
189
189
 
@@ -204,18 +204,22 @@ def _bootstrap_claude_setup_token(session, ctx):
204
204
  "Claude setup-token completed, but cdx could not capture the token. "
205
205
  "Run claude setup-token and save the token under credentials/default.json."
206
206
  )
207
- with open(transcript_path, encoding="utf-8", errors="replace") as handle:
208
- transcript = handle.read()
207
+ try:
208
+ with open(transcript_path, encoding="utf-8", errors="replace") as handle:
209
+ transcript = handle.read()
210
+ finally:
211
+ # The transcript holds the ~1-year OAuth token in cleartext; it must
212
+ # not outlive the extraction, whether it succeeds or not.
213
+ try:
214
+ os.remove(transcript_path)
215
+ except OSError:
216
+ pass
209
217
  token = _extract_claude_oauth_token(transcript)
210
218
  if not token:
211
219
  raise CdxError(
212
220
  "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
213
- f"Transcript kept at: {transcript_path}"
221
+ "Run claude setup-token manually and save the token under credentials/default.json."
214
222
  )
215
- try:
216
- os.remove(transcript_path)
217
- except OSError:
218
- pass
219
223
  auth_home = session.get("authHome") or ""
220
224
  cred_path = _write_claude_oauth_token(auth_home, token)
221
225
  # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
@@ -819,11 +823,12 @@ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_r
819
823
  session for session in ctx["service"]["list_sessions"]()
820
824
  if session.get("provider") == provider and session.get("enabled", True) is not False
821
825
  ]
822
- minimum = _reasoning_rank(min_reasoning_effort)
823
- sessions = [
824
- session for session in sessions
825
- if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
826
- ]
826
+ if min_reasoning_effort:
827
+ minimum = _reasoning_rank(min_reasoning_effort)
828
+ sessions = [
829
+ session for session in sessions
830
+ if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
831
+ ]
827
832
  rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
828
833
  row_by_name = {row.get("session_name"): row for row in rows}
829
834
  candidates = []
@@ -1971,7 +1976,7 @@ def handle_update(rest, ctx):
1971
1976
  base_prefix=ctx["options"].get("basePrefix"),
1972
1977
  )
1973
1978
  results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
1974
- failed = any((result.get("returncode") not in (0, None)) for result in results)
1979
+ failed = any((result.get("returncode") != 0) for result in results)
1975
1980
  if failed:
1976
1981
  raise CdxError(format_update_failure(results))
1977
1982
 
package/src/cli_render.py CHANGED
@@ -10,7 +10,7 @@ def _format_relative_age(iso_value):
10
10
  if not iso_value:
11
11
  return "-"
12
12
  try:
13
- ts = datetime.fromisoformat(iso_value.replace("Z", "+00:00"))
13
+ ts = datetime.fromisoformat(str(iso_value).replace("Z", "+00:00"))
14
14
  delta_s = (datetime.now(timezone.utc) - ts).total_seconds()
15
15
  except (ValueError, TypeError):
16
16
  return "-"
@@ -6,6 +6,8 @@ import subprocess
6
6
  import threading
7
7
  from datetime import datetime, timezone
8
8
 
9
+ from .status_source import _is_zero_credit_balance
10
+
9
11
  try:
10
12
  import fcntl
11
13
  except ImportError: # pragma: no cover - non-POSIX platforms
@@ -92,7 +94,7 @@ def normalize_codex_rate_limit_snapshot(snapshot):
92
94
  credit_balance = None
93
95
  if isinstance(credits, dict):
94
96
  credit_balance = credits.get("balance")
95
- if not credits.get("hasCredits") and not credits.get("unlimited") and str(credit_balance or "0") == "0":
97
+ if not credits.get("hasCredits") and not credits.get("unlimited") and _is_zero_credit_balance(credit_balance):
96
98
  credit_balance = None
97
99
  elif credits is not None:
98
100
  credit_balance = credits
@@ -5,6 +5,7 @@ import subprocess
5
5
  from datetime import datetime
6
6
 
7
7
  from .errors import CdxError
8
+ from .fs_utils import atomic_write
8
9
  from .session_store import _ensure_dir
9
10
 
10
11
  DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
@@ -57,8 +58,7 @@ def read_context(base_dir, cwd=None):
57
58
  def write_context(base_dir, content, cwd=None):
58
59
  path = get_context_path(base_dir, cwd)
59
60
  _ensure_dir(os.path.dirname(path))
60
- with open(path, "w", encoding="utf-8") as handle:
61
- handle.write(content.rstrip() + "\n")
61
+ atomic_write(path, content.rstrip() + "\n", mode=0o644)
62
62
  return {
63
63
  "path": path,
64
64
  "updated_at": _local_now_iso(),
@@ -117,8 +117,7 @@ def install_context_for_session(base_dir, session, cwd=None):
117
117
  raise CdxError(f"Session auth home missing for {session['name']}")
118
118
  target_path = os.path.join(auth_home, "shared-context.md")
119
119
  _ensure_dir(os.path.dirname(target_path))
120
- with open(target_path, "w", encoding="utf-8") as handle:
121
- handle.write(content.rstrip() + "\n")
120
+ atomic_write(target_path, content.rstrip() + "\n", mode=0o644)
122
121
  return {
123
122
  "source_path": get_context_path(base_dir, cwd),
124
123
  "target_path": target_path,
package/src/fs_utils.py CHANGED
@@ -2,6 +2,38 @@ import inspect
2
2
  import os
3
3
  import shutil
4
4
  import stat
5
+ import tempfile
6
+
7
+
8
+ def atomic_write(path, data, mode=None):
9
+ """Write str/bytes via temp file + rename so a crash can't leave a torn file.
10
+
11
+ mkstemp creates the temp file 0o600, so the content is never readable by
12
+ other users mid-write; pass mode to set the final permissions before the
13
+ rename makes the file visible.
14
+ """
15
+ directory = os.path.dirname(os.path.abspath(path))
16
+ os.makedirs(directory, exist_ok=True)
17
+ binary = isinstance(data, (bytes, bytearray))
18
+ fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory)
19
+ try:
20
+ if binary:
21
+ handle = os.fdopen(fd, "wb")
22
+ else:
23
+ handle = os.fdopen(fd, "w", encoding="utf-8")
24
+ with handle:
25
+ handle.write(data)
26
+ handle.flush()
27
+ os.fsync(handle.fileno())
28
+ if mode is not None and os.name != "nt":
29
+ os.chmod(temp_path, mode)
30
+ os.replace(temp_path, path)
31
+ except Exception:
32
+ try:
33
+ os.unlink(temp_path)
34
+ except FileNotFoundError:
35
+ pass
36
+ raise
5
37
 
6
38
 
7
39
  def _make_user_writable(path):
@@ -1,3 +1,4 @@
1
+ import errno
1
2
  import json
2
3
  import os
3
4
  import re
@@ -695,6 +696,45 @@ def _redact_sensitive_args(spec):
695
696
  return [REDACTED_PROMPT_ARG if arg in sensitive else arg for arg in args]
696
697
 
697
698
 
699
+ def _signal_child_group(child, sig):
700
+ # Providers spawn helpers of their own; signal the whole group so
701
+ # grandchildren die with the child. Falls back to False when the child
702
+ # shares our group (test doubles spawned without start_new_session).
703
+ pid = getattr(child, "pid", None)
704
+ if not pid or not hasattr(os, "getpgid"):
705
+ return False
706
+ try:
707
+ pgid = os.getpgid(pid)
708
+ if pgid == os.getpgid(0):
709
+ return False
710
+ os.killpg(pgid, sig)
711
+ return True
712
+ except (OSError, TypeError):
713
+ return False
714
+
715
+
716
+ def _terminate_child_tree(child):
717
+ if not _signal_child_group(child, signal.SIGTERM):
718
+ try:
719
+ child.terminate()
720
+ except Exception:
721
+ pass
722
+ try:
723
+ child.wait(timeout=5)
724
+ return
725
+ except Exception:
726
+ pass
727
+ if not _signal_child_group(child, signal.SIGKILL):
728
+ try:
729
+ child.kill()
730
+ except Exception:
731
+ pass
732
+ try:
733
+ child.wait()
734
+ except Exception:
735
+ pass
736
+
737
+
698
738
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
699
739
  timeout_seconds=None, spawn=None, run_id=None):
700
740
  spawn = spawn or subprocess.Popen
@@ -714,12 +754,16 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
714
754
  timed_out = False
715
755
  with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
716
756
  open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
757
+ options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
758
+ if spawn is subprocess.Popen:
759
+ # Own process group so a timeout can kill grandchildren too.
760
+ options["start_new_session"] = True
717
761
  try:
718
762
  child = spawn(
719
763
  [command] + spec["args"],
720
764
  stdout=stdout_file,
721
765
  stderr=stderr_file,
722
- **{k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")},
766
+ **options,
723
767
  )
724
768
  except FileNotFoundError as error:
725
769
  _combine_headless_transcript(paths)
@@ -740,18 +784,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
740
784
  child.wait()
741
785
  except subprocess.TimeoutExpired:
742
786
  timed_out = True
743
- try:
744
- child.terminate()
745
- child.wait(timeout=5)
746
- except Exception:
747
- try:
748
- child.kill()
749
- except Exception:
750
- pass
751
- try:
752
- child.wait()
753
- except Exception:
754
- pass
787
+ _terminate_child_tree(child)
755
788
 
756
789
  _combine_headless_transcript(paths)
757
790
  end_time = datetime.now(timezone.utc)
@@ -849,6 +882,14 @@ def _format_probe_failure(session, spec, error):
849
882
  f"Install {command} and retry cdx add {session['name']}.",
850
883
  127,
851
884
  )
885
+ if isinstance(error, OSError) and error.errno == errno.ENOEXEC:
886
+ target = getattr(error, "filename", None) or command
887
+ return CdxError(
888
+ f"Failed to check login status for {session['name']}: {target} is not a valid executable "
889
+ f"(corrupted install, wrong architecture, or a script missing its shebang). "
890
+ f"Reinstall {command} and retry.",
891
+ 126,
892
+ )
852
893
  message = getattr(error, "message", None) or str(error)
853
894
  return CdxError(f"Failed to check login status for {session['name']}: {message}")
854
895
 
@@ -883,7 +924,7 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
883
924
  stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
884
925
  stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
885
926
  output = (stdout or "") + (stderr or "")
886
- except FileNotFoundError as error:
927
+ except OSError as error:
887
928
  raise _format_probe_failure(session, spec, error) from error
888
929
  return spec["parser"](output)
889
930