cdx-manager 0.9.16 → 0.11.0

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.
@@ -57,17 +57,16 @@ def _write_json(ctx, payload):
57
57
  ctx["out"](f"{json.dumps(payload, indent=2)}\n")
58
58
 
59
59
 
60
- def _update_notice_warning(ctx):
61
- notices = ctx.get("update_notices") or []
62
- if not notices:
63
- return None
64
- warnings = _update_notice_warnings(ctx)
65
- return warnings[0] if warnings else None
66
-
67
-
68
60
  def _update_notice_warnings(ctx):
69
61
  warnings = []
70
62
  for notice in ctx.get("update_notices") or []:
63
+ if notice.get("code") == "disk_cleanup_available":
64
+ warnings.append({
65
+ "code": "disk_cleanup_available",
66
+ "message": notice["message"],
67
+ **{key: value for key, value in notice.items() if key not in ("code", "message")},
68
+ })
69
+ continue
71
70
  tool = notice.get("tool") or "cdx-manager"
72
71
  current = notice.get("current_version") or ctx.get("version")
73
72
  command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
@@ -111,7 +110,7 @@ def _format_bytes(value):
111
110
  amount /= 1024
112
111
  if unit == "B":
113
112
  return f"{int(amount)} B"
114
- return f"{amount:.1f} {unit}"
113
+ return f"{int(amount)} {unit}" if amount.is_integer() else f"{amount:.1f} {unit}"
115
114
 
116
115
 
117
116
  def _format_export_report(result):
@@ -150,39 +149,16 @@ def _make_export_progress(ctx):
150
149
  return progress
151
150
 
152
151
 
153
- def _make_status_progress(ctx):
154
- progress_state = {"checked": 0, "total": 0}
155
-
156
- def progress(event):
157
- kind = event.get("event")
158
- if kind == "status_started":
159
- progress_state["checked"] = 0
160
- progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
161
- message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
162
- ctx["out"](f"{_info(message, ctx['use_color'])}\n")
163
- elif kind == "session_started":
164
- provider = event.get("provider") or "session"
165
- message = f"Checking {event.get('session_name')} ({provider})..."
166
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
167
- elif kind == "session_finished" and not event.get("cache_hit"):
168
- progress_state["checked"] += 1
169
- total = progress_state["total"] or progress_state["checked"]
170
- message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
171
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
172
- elif kind == "status_finished":
173
- message = f"Resolved {event.get('row_count', 0)} status row(s)."
174
- ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
175
- return progress
176
-
177
-
178
- def _make_notify_progress(ctx):
152
+ def _make_session_progress(ctx, start_event, start_message):
179
153
  progress_state = {"checked": 0, "total": 0}
180
154
 
181
155
  def progress(event):
182
156
  kind = event.get("event")
183
- if kind == "notify_check_started":
184
- target = event.get("session_name") or "next ready session"
185
- ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
157
+ if kind == start_event:
158
+ if kind == "status_started":
159
+ progress_state["checked"] = 0
160
+ progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
161
+ ctx["out"](f"{_info(start_message(event), ctx['use_color'])}\n")
186
162
  elif kind == "status_started":
187
163
  progress_state["checked"] = 0
188
164
  progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
@@ -197,12 +173,31 @@ def _make_notify_progress(ctx):
197
173
  total = progress_state["total"] or progress_state["checked"]
198
174
  message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
199
175
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
176
+ elif kind == "status_finished":
177
+ message = f"Resolved {event.get('row_count', 0)} status row(s)."
178
+ ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
200
179
  elif kind == "notify_waiting":
201
180
  message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
202
181
  ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
203
182
  return progress
204
183
 
205
184
 
185
+ def _make_status_progress(ctx):
186
+ return _make_session_progress(
187
+ ctx,
188
+ "status_started",
189
+ lambda event: f"Resolving status for {event.get('session_count', 0)} session(s)...",
190
+ )
191
+
192
+
193
+ def _make_notify_progress(ctx):
194
+ return _make_session_progress(
195
+ ctx,
196
+ "notify_check_started",
197
+ lambda event: f"Checking notification target: {event.get('session_name') or 'next ready session'}...",
198
+ )
199
+
200
+
206
201
  def _latest_launch_transcript_path(session):
207
202
  paths = _list_launch_transcript_paths(session)
208
203
  if not paths:
package/src/cli_view.py CHANGED
@@ -1,4 +1,4 @@
1
- from .cli_helpers import _json_success, _write_json
1
+ from .cli_helpers import _json_failure, _json_success, _update_notice_warnings, _write_json
2
2
  from .cli_render import _warn
3
3
  from .errors import CdxError
4
4
  from .logics_view import (
@@ -13,31 +13,6 @@ from .update_check import check_logics_manager_for_update
13
13
  VIEW_USAGE = "Usage: cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--open] [--no-open]"
14
14
 
15
15
 
16
- def _update_notice_warnings(notices):
17
- warnings = []
18
- for notice in notices or []:
19
- if not notice:
20
- continue
21
- tool = notice.get("tool") or "logics-manager"
22
- current = notice.get("current_version")
23
- command = notice.get("update_command")
24
- message = f"Update available: {tool} {notice['latest_version']}"
25
- if current:
26
- message = f"{message} (current {current})"
27
- if command:
28
- message = f"{message}. Run: {command}"
29
- warnings.append({
30
- "code": f"{tool.replace('-', '_')}_update_available",
31
- "message": message,
32
- "tool": tool,
33
- "latest_version": notice["latest_version"],
34
- "current_version": current,
35
- "update_command": command,
36
- "url": notice.get("url"),
37
- })
38
- return warnings
39
-
40
-
41
16
  def _logics_manager_update_notice(ctx, env):
42
17
  checker = ctx["options"].get("checkLogicsManagerForUpdate") or check_logics_manager_for_update
43
18
  return checker(
@@ -86,9 +61,12 @@ def handle_view(rest, ctx):
86
61
  update_notice = _logics_manager_update_notice(ctx, env) if executable else None
87
62
  failure = None if executable else missing_logics_manager_failure()
88
63
  diagnostics = build_viewer_diagnostics(executable, cwd, update_notice=update_notice, failure=failure, extra_args=viewer_args)
89
- warnings = _update_notice_warnings([update_notice])
64
+ warnings = _update_notice_warnings({"update_notices": [update_notice] if update_notice else []})
90
65
 
91
66
  if json_flag:
67
+ if failure:
68
+ _write_json(ctx, _json_failure("view", "viewer_unavailable", failure["message"], warnings=warnings, viewer=diagnostics))
69
+ return 1
92
70
  _write_json(ctx, _json_success("view", "Collected Logics viewer diagnostics", warnings=warnings, viewer=diagnostics))
93
71
  return 0
94
72
 
@@ -4,6 +4,7 @@ import os
4
4
  import queue
5
5
  import subprocess
6
6
  import threading
7
+ import time
7
8
  from datetime import datetime, timezone
8
9
 
9
10
  from .status_source import _is_zero_credit_balance
@@ -17,7 +18,7 @@ CODEX_AUTH_LOCK_NAME = ".cdx-auth.lock"
17
18
 
18
19
 
19
20
  @contextlib.contextmanager
20
- def codex_auth_lock(auth_home, blocking=False):
21
+ def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_interval_seconds=0.05):
21
22
  """Serialize codex token refreshes per CODEX_HOME.
22
23
 
23
24
  Codex rotates its OAuth refresh_token on refresh and invalidates the old
@@ -36,13 +37,18 @@ def codex_auth_lock(auth_home, blocking=False):
36
37
  except OSError:
37
38
  yield True
38
39
  return
39
- flags = fcntl.LOCK_EX if blocking else fcntl.LOCK_EX | fcntl.LOCK_NB
40
- try:
41
- fcntl.flock(handle, flags)
42
- except OSError:
43
- handle.close()
44
- yield False
45
- return
40
+ deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
41
+ while True:
42
+ flags = fcntl.LOCK_EX if blocking and timeout_seconds is None else fcntl.LOCK_EX | fcntl.LOCK_NB
43
+ try:
44
+ fcntl.flock(handle, flags)
45
+ break
46
+ except OSError:
47
+ if not blocking or (deadline is not None and time.monotonic() >= deadline):
48
+ handle.close()
49
+ yield False
50
+ return
51
+ time.sleep(retry_interval_seconds)
46
52
  try:
47
53
  yield True
48
54
  finally:
@@ -65,6 +71,15 @@ def _format_reset_date(unix_seconds):
65
71
  return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
66
72
 
67
73
 
74
+ def _format_timestamp(unix_seconds):
75
+ if unix_seconds is None:
76
+ return None
77
+ try:
78
+ return datetime.fromtimestamp(int(unix_seconds), tz=timezone.utc).astimezone().isoformat()
79
+ except (TypeError, ValueError, OSError):
80
+ return None
81
+
82
+
68
83
  def _remaining_from_used_percent(value):
69
84
  if value is None:
70
85
  return None
@@ -84,7 +99,7 @@ def _get_window(snapshot, duration_mins):
84
99
  return {}
85
100
 
86
101
 
87
- def normalize_codex_rate_limit_snapshot(snapshot):
102
+ def normalize_codex_rate_limit_snapshot(snapshot, reset_credits=None):
88
103
  if not snapshot:
89
104
  return None
90
105
 
@@ -101,6 +116,23 @@ def normalize_codex_rate_limit_snapshot(snapshot):
101
116
 
102
117
  reset_5h_at = _format_reset_date(five_hour.get("resetsAt") or five_hour.get("resets_at"))
103
118
  reset_week_at = _format_reset_date(weekly.get("resetsAt") or weekly.get("resets_at"))
119
+ reset_credits = reset_credits or {}
120
+ available_count = reset_credits.get("availableCount", reset_credits.get("available_count"))
121
+ try:
122
+ available_count = max(0, int(available_count)) if available_count is not None else None
123
+ except (TypeError, ValueError):
124
+ available_count = None
125
+ credit_rows = []
126
+ for credit in reset_credits.get("credits") or []:
127
+ if not isinstance(credit, dict) or credit.get("status") not in (None, "available"):
128
+ continue
129
+ credit_rows.append({
130
+ "id": credit.get("id"),
131
+ "title": credit.get("title"),
132
+ "description": credit.get("description"),
133
+ "granted_at": _format_timestamp(credit.get("grantedAt", credit.get("granted_at"))),
134
+ "expires_at": _format_timestamp(credit.get("expiresAt", credit.get("expires_at"))),
135
+ })
104
136
 
105
137
  raw_status_text = json.dumps(snapshot, sort_keys=True)
106
138
  return {
@@ -111,6 +143,8 @@ def normalize_codex_rate_limit_snapshot(snapshot):
111
143
  weekly.get("usedPercent", weekly.get("used_percent"))
112
144
  ),
113
145
  "credits": credit_balance,
146
+ "reset_credits_available": available_count,
147
+ "reset_credits": credit_rows,
114
148
  "reset_5h_at": reset_5h_at,
115
149
  "reset_week_at": reset_week_at,
116
150
  "reset_at": reset_week_at or reset_5h_at,
@@ -166,6 +200,32 @@ def fetch_codex_rate_limit_diagnostic(session, timeout=5, popen_factory=None):
166
200
 
167
201
 
168
202
  def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_factory=None):
203
+ diagnostic = _request_codex_app_server(
204
+ auth_home,
205
+ "account/rateLimits/read",
206
+ None,
207
+ "rate_limits_read_failed",
208
+ timeout=timeout,
209
+ popen_factory=popen_factory,
210
+ )
211
+ if not diagnostic["ok"]:
212
+ return diagnostic
213
+ response = diagnostic["response"]
214
+ result = response.get("result") or {}
215
+ by_limit = result.get("rateLimitsByLimitId") or {}
216
+ snapshot = by_limit.get("codex") or result.get("rateLimits")
217
+ status = normalize_codex_rate_limit_snapshot(snapshot, result.get("rateLimitResetCredits"))
218
+ if not status:
219
+ return {
220
+ "ok": False,
221
+ "reason": "missing_rate_limits",
222
+ "status": None,
223
+ "response": response,
224
+ }
225
+ return {"ok": True, "reason": None, "status": status, "response": response}
226
+
227
+
228
+ def _request_codex_app_server(auth_home, method, params, failure_reason, timeout=5, popen_factory=None):
169
229
  env = os.environ.copy()
170
230
  env["CODEX_HOME"] = auth_home
171
231
  popen_factory = popen_factory or subprocess.Popen
@@ -204,29 +264,18 @@ def _probe_codex_rate_limit_diagnostic(session, auth_home, timeout=5, popen_fact
204
264
  _write_json_line(process, {
205
265
  "jsonrpc": "2.0",
206
266
  "id": 2,
207
- "method": "account/rateLimits/read",
208
- "params": None,
267
+ "method": method,
268
+ "params": params,
209
269
  })
210
270
  response = _read_response(output, 2, timeout)
211
271
  if not response or response.get("error"):
212
272
  return {
213
273
  "ok": False,
214
- "reason": "rate_limits_read_failed",
215
- "status": None,
216
- "response": response,
217
- }
218
- result = response.get("result") or {}
219
- by_limit = result.get("rateLimitsByLimitId") or {}
220
- snapshot = by_limit.get("codex") or result.get("rateLimits")
221
- status = normalize_codex_rate_limit_snapshot(snapshot)
222
- if not status:
223
- return {
224
- "ok": False,
225
- "reason": "missing_rate_limits",
274
+ "reason": failure_reason,
226
275
  "status": None,
227
276
  "response": response,
228
277
  }
229
- return {"ok": True, "reason": None, "status": status, "response": response}
278
+ return {"ok": True, "reason": None, "status": None, "response": response}
230
279
  except FileNotFoundError as error:
231
280
  return {"ok": False, "reason": "codex_cli_not_found", "status": None, "error": str(error)}
232
281
  except (OSError, ValueError, BrokenPipeError) as error:
@@ -250,3 +299,25 @@ def fetch_codex_rate_limits(session, timeout=5, popen_factory=None):
250
299
  popen_factory=popen_factory,
251
300
  )
252
301
  return diagnostic.get("status") if diagnostic.get("ok") else None
302
+
303
+
304
+ def consume_codex_rate_limit_reset_credit(session, idempotency_key, credit_id=None, timeout=5, popen_factory=None):
305
+ auth_home = session.get("authHome")
306
+ if not auth_home:
307
+ return {"ok": False, "reason": "missing_auth_home", "outcome": None}
308
+ with codex_auth_lock(auth_home) as acquired:
309
+ if not acquired:
310
+ return {"ok": False, "reason": "auth_locked", "outcome": None}
311
+ params = {"idempotencyKey": idempotency_key}
312
+ if credit_id:
313
+ params["creditId"] = credit_id
314
+ diagnostic = _request_codex_app_server(
315
+ auth_home,
316
+ "account/rateLimitResetCredit/consume",
317
+ params,
318
+ "reset_consume_failed",
319
+ timeout=timeout,
320
+ popen_factory=popen_factory,
321
+ )
322
+ result = (diagnostic.get("response") or {}).get("result") or {}
323
+ return {**diagnostic, "outcome": result.get("outcome")}
package/src/notify.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import json
2
2
  import os
3
3
  import shlex
4
+ import shutil
4
5
  import subprocess
5
6
  import time
6
7
  from datetime import datetime, timedelta
@@ -165,14 +166,14 @@ def send_desktop_notification(title, message, spawn_sync=None, env=None):
165
166
  env = env or os.environ
166
167
  if sys.platform == "win32":
167
168
  _send_windows_notification(title, message, spawn_sync, env)
168
- elif shutil_which("osascript", env):
169
+ elif shutil.which("osascript", path=env.get("PATH")):
169
170
  script = f'display notification "{_escape_applescript(message)}" with title "{_escape_applescript(title)}"'
170
171
  _run_notification_command(
171
172
  ["osascript", "-e", script],
172
173
  spawn_sync,
173
174
  env,
174
175
  )
175
- elif shutil_which("notify-send", env):
176
+ elif shutil.which("notify-send", path=env.get("PATH")):
176
177
  _run_notification_command(
177
178
  ["notify-send", str(title), str(message)],
178
179
  spawn_sync,
@@ -209,11 +210,6 @@ def _escape_powershell(value):
209
210
  return str(value).replace("'", "''")
210
211
 
211
212
 
212
- def shutil_which(command, env):
213
- import shutil
214
- return shutil.which(command, path=env.get("PATH"))
215
-
216
-
217
213
  def _escape_applescript(value):
218
214
  return str(value).replace("\\", "\\\\").replace('"', '\\"')
219
215
 
@@ -247,7 +243,7 @@ def schedule_notification_event(base_dir, parsed, event, spawn_sync=None, env=No
247
243
 
248
244
 
249
245
  def _scheduled_notify_argv(parsed, env):
250
- executable = env.get("CDX_BIN") or shutil_which("cdx", env) or "cdx"
246
+ executable = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
251
247
  argv = [executable, "notify"]
252
248
  if parsed["mode"] == "at-reset":
253
249
  argv.append(parsed["name"])
@@ -331,7 +327,7 @@ def _launchd_plist(label, script_path, target):
331
327
  def _schedule_linux(parsed, event, argv, spawn_sync, env):
332
328
  unit = _schedule_id("cdx-manager-notify", parsed, event)
333
329
  target = _round_up_to_next_minute(datetime.fromtimestamp(event["target_timestamp"]).astimezone())
334
- if shutil_which("systemd-run", env):
330
+ if shutil.which("systemd-run", path=env.get("PATH")):
335
331
  calendar = target.strftime("%Y-%m-%d %H:%M:%S")
336
332
  result = _run_scheduler_command(
337
333
  ["systemd-run", "--user", f"--unit={unit}", f"--on-calendar={calendar}", *argv],
@@ -343,7 +339,7 @@ def _schedule_linux(parsed, event, argv, spawn_sync, env):
343
339
  if _scheduler_error_means_exists(result["error"]):
344
340
  return {"scheduled": True, "existing": True, "backend": "systemd", "id": unit}
345
341
  raise CdxError(f"Failed to schedule notification with systemd-run: {result['error']}")
346
- if shutil_which("at", env):
342
+ if shutil.which("at", path=env.get("PATH")):
347
343
  at_time = target.strftime("%Y%m%d%H%M.%S")
348
344
  command = " ".join(shlex.quote(str(part)) for part in argv)
349
345
  result = _run_scheduler_command(["at", "-t", at_time], spawn_sync, env, input_text=f"{command}\n")
@@ -16,6 +16,11 @@ from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVI
16
16
  from .errors import CdxError
17
17
 
18
18
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
19
+ AUTH_PROBE_TIMEOUT_SECONDS = 15
20
+ CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
21
+ AUTH_PROBE_AUTHENTICATED = "authenticated"
22
+ AUTH_PROBE_LOGGED_OUT = "logged_out"
23
+ AUTH_PROBE_DEGRADED = "degraded"
19
24
  REASONING_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
20
25
  RTK_PROMPT = (
21
26
  "When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
@@ -173,12 +178,15 @@ def codex_auth_diagnostic(session, spawn_sync=None, env_override=None):
173
178
  "live_error": None,
174
179
  }
175
180
  try:
176
- result["live_status"] = "authenticated" if _probe_provider_auth(
181
+ status = _probe_provider_auth_status(
177
182
  session,
178
183
  spawn_sync=spawn_sync,
179
184
  env_override=env_override,
180
185
  trust_local_credentials=False,
181
- ) else "logged_out"
186
+ )
187
+ result["live_status"] = status
188
+ if status == AUTH_PROBE_DEGRADED:
189
+ result["live_error"] = _auth_probe_degraded_message(session)
182
190
  except CdxError as error:
183
191
  result["live_status"] = "error"
184
192
  result["live_error"] = str(error)
@@ -899,14 +907,21 @@ def _resolve_command(command, env=None):
899
907
  return shutil.which(command, path=env.get("PATH")) or command
900
908
 
901
909
 
902
- def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
910
+ def _auth_probe_degraded_message(session):
911
+ return (
912
+ f"Auth probe timed out after {AUTH_PROBE_TIMEOUT_SECONDS}s for session {session.get('name') or 'unknown'}; "
913
+ "authentication status is degraded."
914
+ )
915
+
916
+
917
+ def _probe_provider_auth_status(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
903
918
  spawn_sync = spawn_sync or subprocess.run
904
919
  spec = _build_login_status_spec(session, env_override)
905
920
  if trust_local_credentials:
906
921
  if session.get("provider") == PROVIDER_CLAUDE and _has_local_claude_auth(_get_auth_home(session)):
907
- return True
922
+ return AUTH_PROBE_AUTHENTICATED
908
923
  if session.get("provider") == PROVIDER_CODEX and _has_local_codex_auth(_get_auth_home(session)):
909
- return True
924
+ return AUTH_PROBE_AUTHENTICATED
910
925
  try:
911
926
  if spawn_sync is subprocess.run:
912
927
  command = _resolve_command(spec["command"], spec["env"])
@@ -914,19 +929,33 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
914
929
  [command] + spec["args"],
915
930
  env=spec["env"],
916
931
  capture_output=True, text=True,
932
+ timeout=AUTH_PROBE_TIMEOUT_SECONDS,
917
933
  )
918
934
  output = (result.stdout or "") + (result.stderr or "")
919
935
  else:
920
936
  result = spawn_sync(spec["command"], spec["args"], spec)
921
937
  error = result.get("error") if isinstance(result, dict) else getattr(result, "error", None)
922
938
  if error:
939
+ if isinstance(error, subprocess.TimeoutExpired):
940
+ raise error
923
941
  raise _format_probe_failure(session, spec, error)
924
942
  stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
925
943
  stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
926
944
  output = (stdout or "") + (stderr or "")
945
+ except subprocess.TimeoutExpired:
946
+ return AUTH_PROBE_DEGRADED
927
947
  except OSError as error:
928
948
  raise _format_probe_failure(session, spec, error) from error
929
- return spec["parser"](output)
949
+ return AUTH_PROBE_AUTHENTICATED if spec["parser"](output) else AUTH_PROBE_LOGGED_OUT
950
+
951
+
952
+ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_local_credentials=True):
953
+ return _probe_provider_auth_status(
954
+ session,
955
+ spawn_sync=spawn_sync,
956
+ env_override=env_override,
957
+ trust_local_credentials=trust_local_credentials,
958
+ ) == AUTH_PROBE_AUTHENTICATED
930
959
 
931
960
 
932
961
  def _signal_exit_code(sig):
@@ -953,10 +982,15 @@ def _run_interactive_provider_command(session, action, spawn=None, cwd=None,
953
982
  lifecycle_callback=lifecycle_callback)
954
983
  if session.get("provider") != PROVIDER_CODEX:
955
984
  return _run_interactive_provider_command_impl(session, action, **kwargs)
956
- # Hold the per-CODEX_HOME lock for the whole session so a concurrent status
957
- # probe backs off instead of rotating the refresh_token mid-run (which logs
958
- # the session out). ponytail: non-blocking, so launch never waits on a probe.
959
- with codex_auth_lock(_get_auth_home(session)):
985
+ # Hold the per-CODEX_HOME lock for the whole session so concurrent Codex
986
+ # processes cannot rotate the refresh_token under each other.
987
+ with codex_auth_lock(
988
+ _get_auth_home(session),
989
+ blocking=True,
990
+ timeout_seconds=CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS,
991
+ ) as acquired:
992
+ if not acquired:
993
+ raise CdxError(f"Timed out waiting for Codex auth lock for session {session['name']}.")
960
994
  return _run_interactive_provider_command_impl(session, action, **kwargs)
961
995
 
962
996
 
@@ -1114,16 +1148,26 @@ def _ensure_session_authentication(session, service, spawn=None, spawn_sync=None
1114
1148
  raise CdxError(
1115
1149
  f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"
1116
1150
  )
1117
- is_authenticated = _probe_provider_auth(
1151
+ auth_status = _probe_provider_auth_status(
1118
1152
  session,
1119
1153
  spawn_sync=spawn_sync,
1120
1154
  env_override=env_override,
1121
1155
  trust_local_credentials=trust_local_credentials,
1122
1156
  )
1123
- if is_authenticated:
1124
- return {"authenticated": True, "checked": True}
1157
+ if auth_status == AUTH_PROBE_AUTHENTICATED:
1158
+ return {"authenticated": True, "checked": True, "status": auth_status}
1159
+ if auth_status == AUTH_PROBE_DEGRADED:
1160
+ result = {
1161
+ "authenticated": False,
1162
+ "checked": True,
1163
+ "status": auth_status,
1164
+ "error": _auth_probe_degraded_message(session),
1165
+ }
1166
+ if behavior == "probe-only":
1167
+ return result
1168
+ raise CdxError(f"{result['error']} Retry, or run: cdx login {session['name']}")
1125
1169
  if behavior == "probe-only":
1126
- return {"authenticated": False, "checked": True}
1170
+ return {"authenticated": False, "checked": True, "status": auth_status}
1127
1171
  if behavior == "launch":
1128
1172
  raise CdxError(
1129
1173
  f"Session {session['name']} is not authenticated. Run: cdx login {session['name']}"