cdx-manager 0.25.3 → 0.25.5

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.25.3-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.25.5-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
 
@@ -178,8 +178,15 @@ result. A probe overlapping the short snapshot can still report contention.
178
178
  Controller renewal does not extend the worker's copied access token; quota remains
179
179
  one controller-side account snapshot, never a worker copy. Multiple independent controllers sharing copied OAuth profiles are not
180
180
  supported. Costs and budget checks use measured worker usage and observed rollout
181
- models; unknown prices and delayed provider reporting retain the usual budget
182
- limitations. A run that outlives the access credential fails with
181
+ models, using the same exact-thread reader and 128 MiB rollout limit as local
182
+ isolated runs. Content-free evidence travels in supervisor frames separated from
183
+ provider stdout; raw rollouts and credentials are never returned. Live rollout
184
+ counters can stop an over-budget run before `turn.completed`. Unknown prices and
185
+ delayed provider reporting retain the usual budget limitations. With `--budget`,
186
+ measured but unpriceable usage still fails with `budget_unverifiable` (exit 125),
187
+ even after the provider finishes, preserving the documented fail-closed guarantee.
188
+ Provider output remains in the run artifacts; without a budget, a completed run
189
+ keeps its provider status and unknown cost. A run that outlives the access credential fails with
183
190
  `remote_credential_expired`, requiring a new controller launch.
184
191
 
185
192
  The empty-refresh-token behavior was measured with Codex CLI 0.166.0. Real-host
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.3
1
+ 0.25.5
@@ -0,0 +1,14 @@
1
+ # CDX Manager 0.25.4
2
+
3
+ Release date: 2026-11-01
4
+
5
+ This patch release measures remote Codex run costs from observed model evidence and applies budgets to those estimates.
6
+
7
+ ## Fixes
8
+
9
+ - Attribute remote run costs to the model observed in Codex usage evidence rather than the requested model. Preserve that evidence in run tracking, stop runs when measured usage exceeds the budget, and report unverifiable budgets explicitly when reliable model evidence or pricing is unavailable. (#83)
10
+
11
+ ## Maintenance
12
+
13
+ - Record tray companion checksums for v0.25.3. (`222d5127314bab4200bb13b1b364b3ab5253fb1b`)
14
+ - Record release archive checksums for v0.25.3. (`9bac8bf616345e4348507038dc392a51eb0a360f`)
@@ -0,0 +1,14 @@
1
+ # CDX Manager 0.25.5
2
+
3
+ Release date: 2026-11-02
4
+
5
+ This patch release makes `cdx update all` report version status based on verified evidence.
6
+
7
+ ## Fixes
8
+
9
+ - Compare installed tool versions with official release sources before reporting them as up to date. Report unknown status when version evidence is unavailable or inconsistent, and offer automatic updates only when a supported CLI updater or verified Homebrew installation is available. Preserve existing JSON fields and expose uncertainty with `unknown` and an optional `reason`. (#85)
10
+
11
+ ## Maintenance
12
+
13
+ - Record tray companion checksums for v0.25.4. (`46ff59ba5fad3bbd7b37429371112f3f6f38a330`)
14
+ - Record release archive checksums for v0.25.4. (`8ae658e2e6f3303f575e95f58b622321cad87a60`)
@@ -1,5 +1,10 @@
1
1
  # Unreleased
2
2
 
3
+ ## Truthful workstation updates
4
+
5
+ - Verify latest versions from npm, RTK GitHub releases, and Antigravity/Ollama vendor update endpoints with short read-only probes. Report `unknown` on unavailable evidence; reserve `up_to_date` for identical known versions, including Ponytail revisions.
6
+ - Preserve the existing JSON fields and `update_available` status, show uncertainty and manual-update reasons in text, and apply only supported native commands or verified Homebrew package upgrades. Cover matching/outdated versions, source failures, missing tools, and command execution with faked sources.
7
+
3
8
  ## Dependency security
4
9
 
5
10
  - Require cryptography >=50.0.0 for encrypted auth bundles and remove the vulnerable 47.0.0 lock entry. Python 3.9 remains supported from 3.9.2, as cryptography 50 excludes 3.9.0 and 3.9.1.
@@ -12,6 +17,13 @@
12
17
  - Add `cdx run --budget USD` to stop on measured estimated spend with exit code 125, `budget_exceeded`, and `error.spent_usd` in the final JSON. Share the budget across recovery attempts and failover; support detached runs and fresh budgets for continued runs without charging earlier turns.
13
18
  - Document unknown usage/prices and delayed provider reporting, including possible budget overshoot. Cover pricing, unknown costs, validation, live process termination, Claude transcript accumulation, continuation boundaries, and detached supervision in tests.
14
19
 
20
+ ## Remote run cost observations
21
+
22
+ - Use the local exact-thread observation reader, usage normalization and 128 MiB rollout limit on workers; return content-free model/counter evidence before volatile storage cleanup and bind it to controller run/attempt artifacts.
23
+ - Separate supervisor evidence frames from provider stdout, and check live rollout measurements before `turn.completed` so measured remote budget excess still stops the provider. Never price from requested models or controller-side rollouts.
24
+ - Preserve the documented fail-closed budget guarantee: measured but unpriceable remote usage exits 125 with `budget_unverifiable`, including after provider completion. Keep provider output in artifacts; completed runs without a budget retain their actual status and unknown cost. Credential snapshots, redaction, cleanup and workspace isolation remain unchanged.
25
+ - Cover priced remote/isolated runs, Windows transport, rollouts over 16 MiB, live budget stops, missing/inconsistent/mixed model evidence, unknown prices and completion without pricing using the fake transport.
26
+
15
27
  ## Remote Codex runs
16
28
 
17
29
  - Add `cdx run --remote SSH_TARGET` with controller-owned supervision, registry, history, output, costs, budgets and detached execution.
@@ -716,6 +716,28 @@
716
716
  "x86_64-pc-windows-msvc": "ffcd4deb8b3843771659d2c6b92b003822b288329ddcf3b9a13f0f9820fa3df1",
717
717
  "x86_64-unknown-linux-musl": "2cefeea0aad5bb0acfcbc99146ffacc5c5da9386f8338e4a01541e97ccf71e07"
718
718
  }
719
+ },
720
+ "v0.25.3": {
721
+ "github_tarball_sha256": "34114563f0edf4d8e13d9410d6a37e4c315571a8410de607b8bc541032561a21",
722
+ "github_zip_sha256": "58cabe7d9d48c314e568c317893b460ca8c8853434a68ee3397e23999f823ddf",
723
+ "tray_assets": {
724
+ "aarch64-apple-darwin": "14da8f42193e78c1bfca5ac046a6a23c7ab52b4155ac9eb213bd9af09cd5e6cc",
725
+ "aarch64-unknown-linux-musl": "47b6988f5f361403e2c8e0c2ff5951cb758af3cde3fc6bb02f7eb718042c3027",
726
+ "x86_64-apple-darwin": "aac73ff0abdb76a957ebd7c143ff3ace46d6df7b08f13db922b28e76dcbc82d0",
727
+ "x86_64-pc-windows-msvc": "c81623ffb94b11ae7b7b28be123b49393240bb0edd31c69969ca07d7eab8dff7",
728
+ "x86_64-unknown-linux-musl": "6892112587c1d8c6e805a90148ec8deb6c04b5927c7ed0c18f19479d521c0ced"
729
+ }
730
+ },
731
+ "v0.25.4": {
732
+ "github_tarball_sha256": "94dbd4f9e2d7e34cf3cfaf967565c1fe8dd56a0cd1fe2888c43cf8f44290a2ec",
733
+ "github_zip_sha256": "13c3bad9b45776fb13a25bf8a363b98d5e8a042b91f848005e7f4adbfb275659",
734
+ "tray_assets": {
735
+ "aarch64-apple-darwin": "06b4b649621b53af642b01ba543369e7ce975454f3b70977e5d35c7a5d3bca5d",
736
+ "aarch64-unknown-linux-musl": "f5ced5b655c525bbcd71eeab002ea557e52507dbe69101ad351fea0bbbf290ff",
737
+ "x86_64-apple-darwin": "2ee4a2144455fc6eebd395c9c58c3d3fa7e4ed333e5f3959ba78e46a41c55be0",
738
+ "x86_64-pc-windows-msvc": "8e2c1925f025a7227ef909f27e8b4696c03bb5a86e0c61c989da6a942b5638a6",
739
+ "x86_64-unknown-linux-musl": "9ae1164abc3ff4b30d25c44696569855138f8ac0e3b6e1c3451c716d20f221fa"
740
+ }
719
741
  }
720
742
  }
721
743
  }
package/docs/cli.md CHANGED
@@ -51,7 +51,7 @@
51
51
  | `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
52
52
  | `cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--tls-cert <path>] [--tls-key <path>] [--open] [--no-open]` | Open the Logics browser/focus viewer by delegating to `logics-manager view`; all viewer flags are forwarded; JSON mode reports diagnostics without launching it |
53
53
  | `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
54
- | `cdx update all [--yes] [--json]` | Check installed providers, RTK, and Ponytail across Codex profiles; show the plan and ask once before applying safe native/Homebrew updates and missing RTK/Ponytail setup |
54
+ | `cdx update all [--yes] [--json]` | Check installed providers, RTK, and Ponytail across Codex profiles; show the plan and ask once before applying supported native/Homebrew updates and missing RTK/Ponytail setup |
55
55
  | `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
56
56
  | `cdx notify` | Internal provider hook target for agent alerts and agent state; use `cdx set <name> --notify on` or `--agent-state on` to enable it |
57
57
  | `cdx agents [--max-age-hours N] [--json]` | Each session's last known agent state (working, waiting, done, failed, limit, ended), waiting first; records older than N hours (default 24) are dropped. See [Agent state](sessions-and-notifications.md#agent-state) |
@@ -254,7 +254,7 @@ At or above the limit, cdx terminates the provider process tree, skips further r
254
254
 
255
255
  With `--detach`, the launch still exits successfully and includes `budget_usd`; cdx always uses its own child supervisor for budgeted runs, including when native background delegation is enabled. Inspect `cdx run-status RUN_ID --json` or `cdx run-report RUN_ID --json` for the child's final payload and budget error. `--continue-run` starts a fresh budget for the new run, including in detached mode.
256
256
 
257
- Conservative assumption: this is a stop based on observed, priceable usage, not a prepaid billing limit. Provider updates can arrive late or only at completion, and termination can take time, so actual spend can exceed the amount. Before the first measurement, `budget_status` is `usage_not_measured`; absence is not zero spend. Once usage is measured, missing continuation boundaries, mixed/unobserved models, and unknown prices stop further execution/recovery with exit **125**, `error.source: "cdx"`, `error.code: "budget_unverifiable"`, `budget_status: "unverifiable"`, and `cost_usd: null`. This is a measurement failure, not a claim that the dollar limit was exceeded or enforced. Supply a `CDX_TOKEN_PRICES` override for a model missing from the table. Existing profile-level `cdx set --budget` settings remain the provider-native Claude cap and apply independently.
257
+ Conservative assumption: this is a stop based on observed, priceable usage, not a prepaid billing limit. Provider updates can arrive late or only at completion, and termination can take time, so actual spend can exceed the amount. Before the first measurement, `budget_status` is `usage_not_measured`; absence is not zero spend. Once usage is measured, missing continuation boundaries, mixed/unobserved models, and unknown prices stop further execution/recovery with exit **125**, `error.source: "cdx"`, `error.code: "budget_unverifiable"`, `budget_status: "unverifiable"`, and `cost_usd: null`. This is a measurement failure, not a claim that the dollar limit was exceeded or enforced. This fail-closed guarantee also applies to a remote agent that has already completed: the supervisor reports `failed`/125 while retaining its final output and artifacts. Without `--budget`, unknown pricing does not replace the provider's completion status. Supply a `CDX_TOKEN_PRICES` override for a model missing from the table. Existing profile-level `cdx set --budget` settings remain the provider-native Claude cap and apply independently.
258
258
 
259
259
  Every usage record, whichever path produced it, uses one set of definitions:
260
260
 
@@ -292,7 +292,7 @@ The figure uses standard public list prices, excluding Fast/priority, Batch/Flex
292
292
 
293
293
  Completed results, registry records, and history JSON include `cost_status` (`estimated` or `unknown`) and nullable `cost_reason`: `usage_not_measured`, `model_not_observed`, `model_price_unknown`, `attempt_usage_unavailable`, or `mixed_models_without_counters`. Stats include `unpriced_reasons` counts alongside `priced_runs` and `unpriced_models`. These are list-price estimates, not billing receipts. `budget_status` is `not_requested`, `usage_not_measured`, `estimated`, `exceeded`, or `unverifiable`.
294
294
 
295
- New headless attempts have distinct `attempt_id` values and artifact paths, including retries on the same profile. Final payloads and registry reports expose `attempts` with their individual model evidence, counters, and estimates; top-level usage describes the final attempt, while a budget-exceeded spend includes all attempts. New isolated Codex attempts mount only a fresh sessions directory from volatile `/dev/shm` storage. The supervisor reads the exact `thread.started` rollout while it exists and at closeout, requiring matching session identity, turn timestamps within this attempt, and token counters consistent with stdout. It exports `usage_evidence` containing only the stdout attempt identity, thread id, timestamp bounds, turn ids, observed models, and normalized counters; live rollout counters can establish the first measurement before `turn.completed`. No requested model, agent-authored cost report, current profile setting, prompt, message, or tool output is used as model evidence. The raw temporary sessions are removed at closeout, including cancellation and startup failure; they are never copied into persistent run artifacts. Host profiles are not mounted. Mixed serving models stay unpriced without attributable counters. Isolated continuation without comparable launch boundaries stays unmeasured rather than charging an older conversation to the new attempt.
295
+ New headless attempts have distinct `attempt_id` values and artifact paths, including retries on the same profile. Final payloads and registry reports expose `attempts` with their individual model evidence, counters, and estimates; top-level usage describes the final attempt, while a budget-exceeded spend includes all attempts. New isolated Codex attempts mount only a fresh sessions directory from volatile `/dev/shm` storage. The supervisor reads the exact `thread.started` rollout while it exists and at closeout, requiring matching session identity, turn timestamps within this attempt, and token counters consistent with stdout. It exports `usage_evidence` containing only the stdout attempt identity, thread id, timestamp bounds, turn ids, observed models, and normalized counters; live rollout counters can establish the first measurement before `turn.completed`. No requested model, agent-authored cost report, current profile setting, prompt, message, or tool output is used as model evidence. The raw temporary sessions are removed at closeout, including cancellation and startup failure; they are never copied into persistent run artifacts. Host profiles are not mounted. Remote Codex runs ship the same observation reader and normalization code over the existing stdin bootstrap. The worker validates the exact fresh-home thread, timestamp bounds and matching counters with the local 128 MiB limit, and returns only normalized evidence through supervisor frames, separate from provider stdout. The controller attaches its own stdout path, run id and attempt id before live budget checks and final persistence; it never consults a controller profile rollout for remote usage. Worker stdout capture stays in the existing volatile private home and is deleted with it. Credentials, refresh handling and workspace mounts are unchanged. Live rollout counters can establish remote usage before `turn.completed`. Mixed serving models stay unpriced without attributable counters. Isolated continuation without comparable launch boundaries stays unmeasured rather than charging an older conversation to the new attempt.
296
296
 
297
297
  Completed run JSON and registry reports expose `usage_model` separately from the requested `model`. Pricing uses the observed model from provider output; Codex can also supply it through the exact rollout identified by `thread.started.thread_id`. Configured defaults alone never establish serving-model identity, and missing or mixed model evidence stays unpriced. `--by model` groups Claude's `[1m]` context variant with its base model while preserving the original model string and per-run pricing. New runs record this evidence automatically. To recover missing models in existing history, run:
298
298
 
@@ -545,3 +545,41 @@ backup, and atomically replaces history only when the complete batch has no
545
545
  conflict. Unrelated fields, usage, and malformed/unrelated history lines survive.
546
546
  Repeating the same mapping is a no-op and creates no additional backup. The
547
547
  registry and provider transcripts are never rewritten by backfill.
548
+
549
+ ### Workstation update checks
550
+
551
+ `cdx update all --json` collects a read-only plan. `--yes` applies its supported
552
+ update and session setup steps. Existing `plan.items` fields remain available:
553
+ `installed`, `version`, `latest_version`, `update_command`, and `status`.
554
+ Versions and commands are explicitly `null` when unavailable.
555
+
556
+ - `up_to_date`: both versions are known and identical.
557
+ - `update_available`: the published version is newer (the tool is outdated).
558
+ - `unknown`: the source failed, returned invalid data, timed out, or the installed
559
+ version could not be compared reliably. This never means up to date.
560
+ - `not_installed`: the executable is absent; no installation is guessed.
561
+
562
+ Checks use the npm registry for Codex and `@anthropic-ai/claude-code`, GitHub's
563
+ `rtk-ai/rtk` latest stable release, the original Antigravity editor's vendor
564
+ `/releases` feed for `agy`, and Ollama's own `/api/update` endpoint with the local
565
+ OS/architecture and baseline version `0.0.0`. Ollama's local `/api/version` reports
566
+ an installed server, not the latest published release, and is not used.
567
+ Antigravity Hub and IDE 2 have separate feeds and are not substituted for `agy`.
568
+ HTTP and read-only CLI probes have a five-second timeout; source failures leave
569
+ `latest_version: null` and `status: unknown`. Ponytail also reports `unknown`
570
+ when its remote revision is unavailable. An empty Ollama response (including
571
+ HTTP 204 without a published version) remains `unknown`; a different platform
572
+ is never substituted to manufacture a match.
573
+
574
+ For an outdated tool, CDX uses a native update command advertised by its
575
+ `--help` (including Claude and Antigravity when available), Codex's updater,
576
+ or Homebrew only when the resolved executable belongs to a Caskroom/Cellar
577
+ package. An outdated tool without such a path retains `update_available`,
578
+ `update_command: null`, and a human-readable `reason`; it requires the vendor's
579
+ manual update mechanism. Source uncertainty never schedules a guessed install.
580
+ The text table shows `UNKNOWN` and the reason, and an empty action list makes
581
+ no claim that all tools are current. JSON consumers, including external
582
+ remote-ops auto-update scripts, should inspect statuses separately from successful
583
+ command execution: exit code zero means no attempted action failed, not that
584
+ every installed tool was verified current. With `--yes`, the returned plan is the
585
+ pre-update inventory; `results` records attempted actions and their exit codes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.25.3",
3
+ "version": "0.25.5",
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.25.3"
7
+ version = "0.25.5"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9.2"
@@ -28,6 +28,7 @@ from ..update_manager import build_update_plan, format_update_failure, run_updat
28
28
  def _format_update_all(plan, use_color=False):
29
29
  labels = {
30
30
  "up_to_date": _success("OK", use_color),
31
+ "unknown": _warn("UNKNOWN", use_color),
31
32
  "update_available": _warn("UPDATE", use_color),
32
33
  "not_installed": _warn("MISSING", use_color),
33
34
  "check_with_native_updater": _info("CHECK", use_color),
@@ -36,6 +37,7 @@ def _format_update_all(plan, use_color=False):
36
37
  for item in plan["items"]:
37
38
  rows.append([_info(item["name"], use_color), item.get("version") or _dim("-", use_color), item.get("latest_version") or _dim("-", use_color), labels.get(item["status"], item["status"].replace("_", " "))])
38
39
  lines = [_style("Workstation update", "1", use_color), _dim("Inventory only — nothing has changed.", use_color), "", _pad_table(rows)]
40
+ lines.extend(f"{item['name']}: {item['reason']}" for item in plan["items"] if item.get("reason"))
39
41
  missing_rtk = plan["setup"]["rtk_missing_sessions"]
40
42
  setup = [f"RTK: enable for {', '.join(missing_rtk)}" if missing_rtk else "RTK: configured for every session"]
41
43
  setup.extend(f"Ponytail {plugin['session']}: {labels.get(plugin['status'], plugin['status'].replace('_', ' '))}" for plugin in plan["setup"]["ponytail"])
@@ -43,7 +45,7 @@ def _format_update_all(plan, use_color=False):
43
45
  if plan["steps"]:
44
46
  lines.extend(["", _warn(f"{len(plan['steps'])} action(s) ready", use_color), _dim("Confirm to update only the tools listed above.", use_color)])
45
47
  else:
46
- lines.extend(["", _success("Everything managed by CDX is current and configured.", use_color)])
48
+ lines.extend(["", _dim("No automatic actions available. See tool statuses and reasons above.", use_color)])
47
49
  return "\n".join(lines)
48
50
 
49
51
  def _format_update_all_result(result, use_color=False):
@@ -689,6 +691,9 @@ def handle_update(rest, ctx):
689
691
  if input("Apply this plan? [y/N] ").strip().lower() not in ("y", "yes"):
690
692
  ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
691
693
  return 0
694
+ if parsed["yes"] and not parsed["json"]:
695
+ ctx["out"](f"{_format_update_all(plan, ctx['use_color'])}\n")
696
+
692
697
  def progress(event):
693
698
  if parsed["json"]:
694
699
  return
@@ -584,7 +584,7 @@ def _observe_headless_usage(session, run_info, baseline=None):
584
584
 
585
585
  def _budget_observation(session, paths, baseline):
586
586
  observation = _observe_headless_usage(session, paths, baseline if session["provider"] == "codex" else None)
587
- if (session.get("launch") or {}).get("isolate_workspace"):
587
+ if paths.get("remote") or (session.get("launch") or {}).get("isolate_workspace"):
588
588
  return observation
589
589
  identifier = (extract_run_conversation_id(session["provider"], paths.get("stdout_path"))
590
590
  or ((session.get("conversation") or {}).get("id")
package/src/remote_run.py CHANGED
@@ -7,6 +7,7 @@ import os
7
7
  import pathlib
8
8
  import re
9
9
  import subprocess
10
+ import threading
10
11
  import time
11
12
  from contextlib import contextmanager
12
13
  from datetime import datetime, timezone
@@ -82,6 +83,11 @@ def worker_input():
82
83
  # One JSON line frames the source, leaving the next line for the payload.
83
84
  directory = pathlib.Path(__file__).parent
84
85
  source = 'SANDBOX_SOURCE = ' + repr((directory / 'workspace_sandbox.py').read_text()) + '\n'
86
+ from .interactive_usage import MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_SCAN_SECONDS
87
+ modules = {name: (directory / (name + '.py')).read_text()
88
+ for name in ('errors', 'run_usage', 'run_observation')}
89
+ source += 'OBSERVATION_MODULES = ' + repr(modules) + '\n'
90
+ source += f'OBSERVATION_LIMITS = {MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_SCAN_SECONDS!r}\n'
85
91
  source += (directory / 'remote_worker.py').read_text()
86
92
  return json.dumps(source).encode() + b'\n'
87
93
 
@@ -167,27 +173,57 @@ def run_remote(session, target, version, cwd=None, env_override=None, initial_pr
167
173
  paths = _headless_artifact_paths(session, run_id, attempt)
168
174
  paths['remote'] = target
169
175
  start = datetime.now(timezone.utc)
170
- child = None
176
+ child = receiver = out = err = None
171
177
  cancelled = timed_out = budget_exceeded = credential_expired = False
172
178
  handlers = _route_termination_to_interrupt()
179
+ observation_ready = threading.Event()
180
+ observation_lock = threading.Lock()
181
+
182
+ def receive(source, output):
183
+ for line in source:
184
+ try:
185
+ frame = json.loads(line)
186
+ if frame.get('stream') == 'stdout':
187
+ with observation_lock:
188
+ observation_ready.clear()
189
+ output.write(frame['data'])
190
+ output.flush()
191
+ elif frame.get('stream') == 'observation':
192
+ evidence = frame['evidence']
193
+ evidence.update(stdout_path=paths['stdout_path'], run_id=paths['run_id'], attempt_id=paths.get('attempt_id'))
194
+ with observation_lock:
195
+ paths['usage_evidence'] = evidence
196
+ observation_ready.set()
197
+ except (ValueError, TypeError, KeyError, AttributeError):
198
+ continue
199
+
173
200
  try:
174
- with open(paths['stdout_path'], 'w') as out, open(paths['stderr_path'], 'w') as err:
175
- child = subprocess.Popen(ssh_command(target, 'run', remote_prefix), env={**os.environ, **(env_override or {})},
176
- stdin=subprocess.PIPE, stdout=out, stderr=err)
177
- child.stdin.write(worker_input() + json.dumps(payload).encode() + b'\n')
178
- child.stdin.flush()
179
- deadline = time.monotonic() + timeout_seconds if timeout_seconds else None
180
- while child.poll() is None:
181
- if time.time() >= payload['expires_at']:
182
- credential_expired = True
183
- break
184
- if budget_check and budget_check(paths):
185
- budget_exceeded = True
186
- break
187
- if deadline and time.monotonic() >= deadline:
188
- timed_out = True
189
- break
190
- time.sleep(0.1)
201
+ out = open(paths['stdout_path'], 'w', encoding='utf-8')
202
+ err = open(paths['stderr_path'], 'w', encoding='utf-8')
203
+ child = subprocess.Popen(ssh_command(target, 'run', remote_prefix), env={**os.environ, **(env_override or {})},
204
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=err)
205
+ receiver = threading.Thread(target=receive, args=(child.stdout, out))
206
+ receiver.start()
207
+ child.stdin.write(worker_input() + json.dumps(payload).encode() + b'\n')
208
+ child.stdin.flush()
209
+ deadline = time.monotonic() + timeout_seconds if timeout_seconds else None
210
+ while child.poll() is None:
211
+ if time.time() >= payload['expires_at']:
212
+ credential_expired = True
213
+ break
214
+ with observation_lock:
215
+ if budget_check and observation_ready.is_set():
216
+ observation_ready.clear()
217
+ exceeded = budget_check(paths)
218
+ else:
219
+ exceeded = False
220
+ if exceeded:
221
+ budget_exceeded = True
222
+ break
223
+ if deadline and time.monotonic() >= deadline:
224
+ timed_out = True
225
+ break
226
+ time.sleep(0.1)
191
227
  except KeyboardInterrupt:
192
228
  cancelled = True
193
229
  except (OSError, ValueError) as error:
@@ -208,6 +244,12 @@ def run_remote(session, target, version, cwd=None, env_override=None, initial_pr
208
244
  except subprocess.TimeoutExpired:
209
245
  child.kill()
210
246
  child.wait()
247
+ if receiver:
248
+ receiver.join()
249
+ if out:
250
+ out.close()
251
+ if err:
252
+ err.close()
211
253
  _restore_signal_handlers(handlers)
212
254
  _combine_headless_transcript(paths)
213
255
  info = {**_headless_run_info(paths, spec, start, child.returncode), 'remote': target,
@@ -12,9 +12,26 @@ import sys
12
12
  import tempfile
13
13
  import threading
14
14
  import time
15
+ import types
16
+ from datetime import datetime, timezone
15
17
 
16
18
  # Supplied by the controller before this module is executed.
17
19
  SANDBOX_SOURCE = globals().get('SANDBOX_SOURCE', '')
20
+ OBSERVATION_MODULES = globals().get('OBSERVATION_MODULES', {})
21
+ OBSERVATION_LIMITS = globals().get('OBSERVATION_LIMITS', (128 * 1024 * 1024, 1))
22
+
23
+
24
+ def observation_runtime():
25
+ # Load only controller-owned readers, with no worker installation required.
26
+ package = types.ModuleType('cdx_observation')
27
+ package.__path__ = []
28
+ sys.modules[package.__name__] = package
29
+ for name, source in OBSERVATION_MODULES.items():
30
+ module = types.ModuleType(package.__name__ + '.' + name)
31
+ module.__package__ = package.__name__
32
+ sys.modules[module.__name__] = module
33
+ exec(compile(source, name + '.py', 'exec'), module.__dict__)
34
+ return sys.modules[package.__name__ + '.run_usage'], sys.modules[package.__name__ + '.run_observation']
18
35
 
19
36
 
20
37
  def version():
@@ -144,6 +161,7 @@ def main():
144
161
  ['codex', *args], pathlib.Path(payload['cwd']), network=payload['network'],
145
162
  writable=payload['writable'], source=env, auth_home=str(home), provider='codex',
146
163
  observation_home=str(home / 'sessions'))
164
+ started_at = datetime.now(timezone.utc).isoformat()
147
165
  child = subprocess.Popen(command, cwd=payload['cwd'], env=env,
148
166
  stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
149
167
  start_new_session=True)
@@ -153,36 +171,36 @@ def main():
153
171
  secrets = [value for value in auth['tokens'].values() if isinstance(value, str) and value]
154
172
 
155
173
  output_lock = threading.Lock()
156
- observed = {}
174
+ usage_reader, observation_reader = observation_runtime()
175
+ stdout_path = home / 'stdout.jsonl'
176
+ stdout_path.touch(mode=0o600)
157
177
 
158
178
  def snapshot():
159
- # Only the fresh per-run home supplies model evidence. No transcript
160
- # or authentication content leaves the worker.
179
+ # Read the same exact-thread evidence as the local supervisor before
180
+ # volatile storage is removed. Only normalized metadata is exported.
161
181
  with output_lock:
162
- identifier = observed.get('thread_id')
163
- usage = observed.get('usage')
164
- if not identifier or not usage:
165
- return
166
- for path in (home / 'sessions').rglob('*.jsonl'):
167
- if path.is_symlink() or path.stat().st_size > 16 * 1024 * 1024:
168
- continue
169
- try:
170
- records = [json.loads(line) for line in path.read_text().splitlines()]
171
- except (OSError, ValueError):
172
- continue
173
- if not any(row.get('type') == 'session_meta' and row.get('payload', {}).get('id') == identifier for row in records):
174
- continue
175
- contexts = [row.get('payload', {}) for row in records if row.get('type') == 'turn_context']
176
- models = {row.get('model') for row in contexts}
177
- counts = [row.get('payload', {}).get('info', {}).get('total_token_usage') for row in records
178
- if row.get('type') == 'event_msg' and row.get('payload', {}).get('type') == 'token_count']
179
- if (len(models) == 1 and None not in models and counts and counts[-1]
180
- and all(counts[-1].get(key) == usage.get(key) for key in ('input_tokens', 'output_tokens', 'cached_input_tokens'))):
181
- event = json.dumps({'type': 'cdx.remote.usage', 'usage': usage, 'model': next(iter(models))})
182
- for secret in secrets:
183
- event = event.replace(secret, '[REDACTED]')
184
- print(event, flush=True)
185
- return
182
+ usage = usage_reader.extract_run_usage('codex', stdout_path)
183
+ identifier = usage_reader.extract_run_conversation_id('codex', stdout_path)
184
+ evidence = {'source': 'isolated_codex_rollout', 'thread_id': identifier,
185
+ 'started_at': started_at, 'usage': usage, 'models': [], 'turns': []}
186
+ if identifier:
187
+ paths = []
188
+ deadline = time.monotonic() + OBSERVATION_LIMITS[1]
189
+ for directory, subdirs, names in os.walk(home / 'sessions'):
190
+ if time.monotonic() > deadline:
191
+ paths = []
192
+ break
193
+ subdirs[:] = [name for name in subdirs if name != 'subagents']
194
+ paths.extend(pathlib.Path(directory) / name for name in names
195
+ if name.startswith('rollout-') and name.endswith(f'-{identifier}.jsonl'))
196
+ if len(paths) > 1:
197
+ break
198
+ if len(paths) == 1:
199
+ evidence = observation_reader.codex_rollout_evidence(home, paths[0], evidence, OBSERVATION_LIMITS[0])
200
+ event = json.dumps({'stream': 'observation', 'evidence': evidence})
201
+ for secret in secrets:
202
+ event = event.replace(secret, '[REDACTED]')
203
+ print(event, flush=True)
186
204
 
187
205
  def forward(source, target):
188
206
  for line in iter(source.readline, b''):
@@ -190,16 +208,13 @@ def main():
190
208
  line = line.replace(secret.encode(), b'[REDACTED]')
191
209
  with output_lock:
192
210
  if target is sys.stdout:
193
- try:
194
- event = json.loads(line)
195
- if event.get('type') == 'thread.started':
196
- observed['thread_id'] = event.get('thread_id')
197
- if event.get('type') == 'turn.completed':
198
- observed['usage'] = event.get('usage')
199
- except (ValueError, AttributeError):
200
- pass
201
- target.buffer.write(line)
202
- target.buffer.flush()
211
+ with stdout_path.open('ab') as capture:
212
+ capture.write(line)
213
+ # Frame provider output so it cannot forge supervisor evidence.
214
+ print(json.dumps({'stream': 'stdout', 'data': line.decode('utf-8', errors='replace')}), flush=True)
215
+ else:
216
+ target.buffer.write(line)
217
+ target.buffer.flush()
203
218
 
204
219
  threads = [threading.Thread(target=forward, args=pair) for pair in
205
220
  ((child.stdout, sys.stdout), (child.stderr, sys.stderr))]
@@ -6,8 +6,6 @@ import stat
6
6
  from datetime import datetime, timezone
7
7
  from pathlib import Path
8
8
 
9
- from .history_models import _exact_transcript
10
- from .interactive_usage import MAX_TRANSCRIPT_BYTES
11
9
  from .run_usage import _usage_from_dict, extract_run_conversation_id, extract_run_usage
12
10
 
13
11
 
@@ -31,6 +29,9 @@ def codex_attempt_evidence(auth_home, stdout_path, started_at):
31
29
  Do not import claimed costs, requested models, messages, or arbitrary files
32
30
  from the workspace. The exported object is never writable by the sandbox.
33
31
  """
32
+ from .history_models import _exact_transcript
33
+ from .interactive_usage import MAX_TRANSCRIPT_BYTES
34
+
34
35
  usage = extract_run_usage("codex", stdout_path)
35
36
  identifier = extract_run_conversation_id("codex", stdout_path)
36
37
  evidence = {"source": "isolated_codex_rollout", "stdout_path": str(stdout_path),
@@ -41,6 +42,14 @@ def codex_attempt_evidence(auth_home, stdout_path, started_at):
41
42
  path, error = _exact_transcript("codex", auth_home, identifier)
42
43
  if error or not path:
43
44
  return evidence
45
+ return codex_rollout_evidence(auth_home, path, evidence, MAX_TRANSCRIPT_BYTES)
46
+
47
+
48
+ def codex_rollout_evidence(auth_home, path, evidence, max_bytes):
49
+ """Validate exact-attempt rollout evidence identically on local and remote hosts."""
50
+ usage = evidence["usage"]
51
+ identifier = evidence["thread_id"]
52
+ started_at = evidence["started_at"]
44
53
  models = set()
45
54
  turns = []
46
55
  identity = False
@@ -52,8 +61,8 @@ def codex_attempt_evidence(auth_home, stdout_path, started_at):
52
61
  with os.fdopen(descriptor, encoding="utf-8") as handle:
53
62
  if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
54
63
  return evidence
55
- text = handle.read(MAX_TRANSCRIPT_BYTES + 1)
56
- if len(text) > MAX_TRANSCRIPT_BYTES:
64
+ text = handle.read(max_bytes + 1)
65
+ if len(text) > max_bytes:
57
66
  return evidence
58
67
  for line in text.splitlines():
59
68
  record = json.loads(line)
package/src/update_all.py CHANGED
@@ -2,19 +2,24 @@
2
2
 
3
3
  import json
4
4
  import os
5
+ import platform
5
6
  import re
6
7
  import shutil
7
8
  import subprocess
9
+ from urllib.parse import urlencode, urlparse
10
+ from urllib.request import Request, urlopen
8
11
 
9
12
  from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
10
13
 
11
14
  _TOOLS = (
12
- (PROVIDER_CODEX, "codex", None, ["codex", "update"]),
13
- (PROVIDER_CLAUDE, "claude", "claude", ["brew", "upgrade", "--cask", "claude"]),
14
- (PROVIDER_ANTIGRAVITY, "agy", "antigravity", ["brew", "upgrade", "--cask", "antigravity"]),
15
- (PROVIDER_OLLAMA, "ollama", "ollama", ["brew", "upgrade", "ollama"]),
16
- ("rtk", "rtk", "rtk", ["brew", "upgrade", "rtk"]),
15
+ (PROVIDER_CODEX, "codex"),
16
+ (PROVIDER_CLAUDE, "claude"),
17
+ (PROVIDER_ANTIGRAVITY, "agy"),
18
+ (PROVIDER_OLLAMA, "ollama"),
19
+ ("rtk", "rtk"),
17
20
  )
21
+ _SOURCE_TIMEOUT = 5
22
+ _ANTIGRAVITY_RELEASES = "https://antigravity-auto-updater-974169037036.us-central1.run.app/releases"
18
23
  _PONYTAIL_URL = "https://github.com/DietrichGebert/ponytail.git"
19
24
 
20
25
 
@@ -41,45 +46,76 @@ def _run(command, env, runner=None, timeout=15):
41
46
  return {"returncode": result.returncode, "stdout": result.stdout or "", "stderr": result.stderr or ""}
42
47
 
43
48
 
49
+ def _parse_version(value):
50
+ if isinstance(value, str) and re.fullmatch(r"v?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", value):
51
+ return value.lstrip("v")
52
+ return None
53
+
54
+
44
55
  def _version(command, env, runner):
45
- result = _run([command, "--version"], env, runner)
56
+ result = _run([command, "--version"], env, runner, timeout=_SOURCE_TIMEOUT)
57
+ if result["returncode"]:
58
+ return None
46
59
  text = (result["stdout"] or result["stderr"] or "").strip()
47
- match = re.search(r"\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b", text)
48
- return match.group(1) if match else None
60
+ # Ollama can print the server version before the CLI version.
61
+ client = re.search(r"client version is (\S+)", text)
62
+ match = client or re.search(r"\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b", text)
63
+ return _parse_version(match.group(1)) if match else None
49
64
 
50
65
 
51
- def _npm_latest(package, env, runner):
52
- if not _which("npm", env):
66
+ def _fetch_json(url):
67
+ request = Request(url, headers={"Accept": "application/json", "User-Agent": "cdx-manager-update-check"})
68
+ with urlopen(request, timeout=_SOURCE_TIMEOUT) as response:
69
+ return json.loads(response.read(1024 * 1024))
70
+
71
+
72
+ def _latest_version(name):
73
+ try:
74
+ if name in (PROVIDER_CODEX, PROVIDER_CLAUDE):
75
+ package = "@openai/codex" if name == PROVIDER_CODEX else "@anthropic-ai/claude-code"
76
+ return _parse_version(_fetch_json(f"https://registry.npmjs.org/{package}/latest")["version"])
77
+ if name == "rtk":
78
+ release = _fetch_json("https://api.github.com/repos/rtk-ai/rtk/releases/latest")
79
+ if release.get("draft") or release.get("prerelease"):
80
+ return None
81
+ return _parse_version(release["tag_name"])
82
+ if name == PROVIDER_ANTIGRAVITY:
83
+ # agy belongs to the original editor, whose feed differs from Hub and IDE 2.
84
+ return _parse_version(_fetch_json(_ANTIGRAVITY_RELEASES)[0]["version"])
85
+ if name == PROVIDER_OLLAMA:
86
+ arch = {"x86_64": "amd64", "aarch64": "arm64"}.get(platform.machine().lower(), platform.machine().lower())
87
+ query = urlencode({"os": platform.system().lower(), "arch": arch, "version": "0.0.0"})
88
+ release = _fetch_json(f"https://ollama.com/api/update?{query}")
89
+ # The vendor updater extracts the release tag from the artifact URL.
90
+ return _parse_version(os.path.basename(os.path.dirname(urlparse(release["url"]).path)))
91
+ except Exception:
92
+ # A failed or changed source is uncertainty, never evidence of currency.
53
93
  return None
54
- result = _run(["npm", "view", package, "version"], env, runner)
55
- value = (result["stdout"] or "").strip()
56
- return value if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", value) else None
94
+ return None
57
95
 
58
96
 
59
97
  def _newer(latest, current):
60
98
  def key(value):
61
- return tuple(int(part) for part in str(value).split("-", 1)[0].split("+", 1)[0].split("."))
99
+ return tuple(int(part) for part in value.split("-", 1)[0].split("+", 1)[0].split("."))
62
100
  return bool(latest and current and key(latest) > key(current))
63
101
 
64
102
 
65
- def _brew_outdated(env, runner):
66
- if not _which("brew", env):
67
- return {}
68
- result = _run(["brew", "outdated", "--json=v2"], env, runner, timeout=30)
69
- try:
70
- payload = json.loads(result["stdout"])
71
- except (TypeError, ValueError):
72
- return {}
73
- rows = {}
74
- for group in ("formulae", "casks"):
75
- for item in payload.get(group, []):
76
- rows[item.get("name")] = item
77
- return rows
78
-
79
-
80
- def _brew_cask_for_path(path):
81
- match = re.search(r"/Caskroom/([^/]+)/", os.path.realpath(path or ""))
82
- return match.group(1) if match else None
103
+ def _native_update_command(name, command, path, env, runner):
104
+ real_path = os.path.realpath(path)
105
+ cask = re.search(r"/Caskroom/([^/]+)/", real_path)
106
+ formula = re.search(r"/Cellar/([^/]+)/", real_path)
107
+ if (cask or formula) and _which("brew", env):
108
+ package = (cask or formula).group(1)
109
+ return ["brew", "upgrade", *(["--cask"] if cask else []), package]
110
+ if name == PROVIDER_CODEX:
111
+ return [command, "update"]
112
+ help_result = _run([command, "--help"], env, runner, timeout=_SOURCE_TIMEOUT)
113
+ if help_result["returncode"] == 0:
114
+ help_text = help_result["stdout"]
115
+ for subcommand in ("self-update", "update", "--update"):
116
+ if re.search(r"(?m)^\s*" + re.escape(subcommand) + r"(?:\s|$)", help_text):
117
+ return [command, subcommand]
118
+ return None
83
119
 
84
120
 
85
121
  def _ponytail_revision(auth_home, env=None, runner=None):
@@ -99,26 +135,27 @@ def _ponytail_revision(auth_home, env=None, runner=None):
99
135
 
100
136
  def collect_update_all_plan(service, env=None, runner=None):
101
137
  env = dict(env or os.environ)
102
- outdated = _brew_outdated(env, runner)
103
138
  items, steps = [], []
104
- for name, command, brew_name, update_command in _TOOLS:
139
+ for name, command in _TOOLS:
105
140
  path = _which(command, env)
106
- item = {"name": name, "command": command, "installed": bool(path), "path": path, "version": _version(command, env, runner) if path else None}
107
- cask_name = _brew_cask_for_path(path)
108
- brew_target = cask_name or brew_name
109
- brew_row = outdated.get(brew_target) if brew_target else None
110
- if brew_row:
111
- command_to_run = ["brew", "upgrade", "--cask", brew_target] if cask_name else update_command
112
- item.update({"status": "update_available", "latest_version": brew_row.get("current_version"), "update_command": command_to_run, "package": brew_target})
113
- steps.append({"name": name, "command": command_to_run, "env": {}})
114
- elif path and name == PROVIDER_CODEX:
115
- # Codex owns its installer; it is the only reliable updater across npm, Homebrew and standalone installs.
116
- latest = _npm_latest("@openai/codex", env, runner)
117
- item.update({"latest_version": latest, "status": "update_available" if _newer(latest, item["version"]) else "up_to_date" if latest else "check_with_native_updater", "update_command": update_command})
118
- if item["status"] != "up_to_date":
119
- steps.append({"name": name, "command": update_command, "env": {}})
120
- else:
121
- item["status"] = "up_to_date" if path else "not_installed"
141
+ current = _version(command, env, runner) if path else None
142
+ item = {"name": name, "command": command, "installed": bool(path), "path": path,
143
+ "version": current, "latest_version": None, "update_command": None, "status": "not_installed"}
144
+ if path:
145
+ latest = _latest_version(name)
146
+ item["latest_version"] = latest
147
+ if latest and current and latest == current:
148
+ item["status"] = "up_to_date"
149
+ elif _newer(latest, current):
150
+ item["status"] = "update_available"
151
+ item["update_command"] = _native_update_command(name, command, path, env, runner)
152
+ if item["update_command"]:
153
+ steps.append({"name": name, "command": item["update_command"], "env": {}})
154
+ else:
155
+ item["reason"] = "No supported CLI updater or verified Homebrew installation; use the vendor's updater manually."
156
+ else:
157
+ item["status"] = "unknown"
158
+ item["reason"] = "Latest version unavailable from the official source." if not latest else "Installed version unavailable or does not match the published release."
122
159
  items.append(item)
123
160
 
124
161
  rtk_available = any(item["name"] == "rtk" and item["installed"] for item in items)
@@ -131,13 +168,14 @@ def collect_update_all_plan(service, env=None, runner=None):
131
168
  ponytail_head = None
132
169
  if any(_ponytail_revision(session.get("authHome") or "", env, runner) for session in codex_sessions):
133
170
  remote = _run(["git", "ls-remote", _PONYTAIL_URL, "HEAD"], env, runner)
134
- ponytail_head = (remote["stdout"] or "").split("\t", 1)[0].strip() or None
171
+ candidate = (remote["stdout"] or "").split("\t", 1)[0].strip()
172
+ ponytail_head = candidate if remote["returncode"] == 0 and re.fullmatch(r"[0-9a-f]{40}", candidate) else None
135
173
  for session in codex_sessions:
136
174
  revision = _ponytail_revision(session.get("authHome") or "", env, runner)
137
175
  row = {"session": session["name"], "installed": bool(revision), "revision": revision}
138
176
  if revision:
139
177
  row["latest_revision"] = ponytail_head
140
- row["status"] = "update_available" if ponytail_head and not ponytail_head.startswith(revision) else "up_to_date"
178
+ row["status"] = "update_available" if ponytail_head and not ponytail_head.startswith(revision) else "up_to_date" if ponytail_head else "unknown"
141
179
  if row["status"] == "update_available":
142
180
  steps.append({"name": f"update Ponytail ({session['name']})", "command": ["codex", "plugin", "marketplace", "upgrade", "ponytail", "--json"], "env": {"CODEX_HOME": session["authHome"]}})
143
181
  else: