cdx-manager 0.25.2 → 0.25.4

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.2-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.4-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
 
@@ -88,3 +88,108 @@ Recovery does not require `--failover`, which still controls account quota exhau
88
88
  - [Tray companion testing](docs/tray-local-testing.md) — local development loops.
89
89
 
90
90
  Contributions are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). CDX Manager is [MIT licensed](LICENSE).
91
+
92
+ ### Run Codex on an SSH worker
93
+
94
+ ```sh
95
+ cdx run work --remote agent@worker --cwd /srv/project --prompt 'Run the checks' --json
96
+ cdx run work --remote worker --cwd /srv/project --prompt-file task.txt --detach --label checks --budget 2 --json
97
+ cdx run work --remote Spire --remote-prefix 'wsl.exe -d Ubuntu --' --cwd /srv/project --prompt 'Run the checks' --json
98
+ ```
99
+
100
+ The controller remains the authority for the profile, quota, history, costs and
101
+ run registry. Use `run-status`, `run-report` and `run-tail` on the controller with
102
+ the returned run ID. Permission, model, reasoning effort, prompt, timeout, budget,
103
+ label and detached execution use the existing run options. `--cwd` names an
104
+ absolute directory on the worker; it need not exist on the controller. Files are
105
+ not synchronized. Use `--project` to explicitly attribute a remote checkout to a
106
+ project. Remote runs do not retry or fail over automatically, including when
107
+ `--failover` is supplied.
108
+
109
+ Requirements: a POSIX controller with `ssh` and Codex on PATH, and a Linux worker
110
+ with Python 3, memory-backed `/dev/shm`, and exactly the same `codex --version`.
111
+ Configure SSH keys, host trust and aliases in advance; the connection uses batch
112
+ mode and bounded connection/keepalive timeouts. Provider refusal, unreachable
113
+ workers and version mismatches fail before sending credentials. Only Codex OAuth
114
+ profiles are supported. Remote continuation is refused because the temporary home
115
+ does not preserve conversation rollouts. Codex's native permission sandbox still
116
+ applies to ordinary remote runs.
117
+
118
+ For SSH servers landing in Windows `cmd.exe` (including Spire and Citadel), pass
119
+ `--remote-prefix 'wsl.exe -d Ubuntu --'` to enter Linux before starting Python.
120
+ The prefix accepts plain command words, options and paths without spaces or shell
121
+ metacharacters; it is forwarded to detached runs. POSIX targets need no prefix.
122
+ Only a short, double-quoted Python bootstrap is sent in the SSH command line;
123
+ the worker and shared sandbox source are framed on stdin before the payload.
124
+ Neither source size nor prompt size consumes cmd.exe's 8191-character command limit.
125
+
126
+ `--remote` supports `--isolate-workspace` using the same local isolation mechanism:
127
+ bubblewrap, pasta (passt), nftables, Python, the checkout/toolchain allowlist,
128
+ and an actual namespace/network setup using a dummy credential. Missing dependencies,
129
+ namespace denial or invalid checkouts fail before sending real credentials, without
130
+ an unisolated fallback.
131
+
132
+ For **every isolated Codex run**, local or remote, the sandbox mounts only a
133
+ run-private, mode-600 `auth.json` on verified memory-backed storage, with an empty
134
+ `refresh_token`. It never mounts the controller profile's full authentication file.
135
+ The controller refreshes under the authentication lock at the thresholds below
136
+ before preparing the snapshot, then releases the lock for the run. The temporary
137
+ home is removed after success, failure, launch errors and stop. Non-isolated local
138
+ runs and interactive login/refresh retain their normal profile authentication.
139
+
140
+ **Accepted residual risk (operator decision, 2026-11-01):** agent tools inside the
141
+ sandbox can read the copied access token, which can remain valid for up to ten
142
+ days. A read-only mount prevents modification, not disclosure. The rotating refresh
143
+ token never enters the sandbox or leaves the controller. Codex was measured to run
144
+ with an empty refresh token on 2026-11-01.
145
+
146
+ A long isolated Tower mission keeps its original credential snapshot even when
147
+ scheduled controller refresh updates the real profile. There is no authentication
148
+ writeback or token renewal inside the sandbox. A run that outlives its access token
149
+ fails with a clear temporary-credential expiry error and must start a new run.
150
+
151
+ The controller takes the profile's authentication lock and refreshes through its
152
+ local [Codex app-server](https://learn.chatgpt.com/docs/app-server) when `last_refresh` is older than seven days or the access
153
+ token has less than 24 hours left. It verifies freshness after refreshing and
154
+ fails closed if freshness or expiration cannot be established. Only
155
+ `access_token`, `id_token`, `account_id` and `last_refresh` travel, with an empty
156
+ `refresh_token`. The rotating refresh token never leaves the controller. The
157
+ credential travels exclusively over SSH stdin, and is absent from command
158
+ arguments, environment variables, run records and output. The agent receives
159
+ `/dev/null` as stdin, not the bootstrap script or credential transport.
160
+
161
+ Each worker run gets a private mode-700 home under
162
+ `/dev/shm/cdx-remote-<uid>/run-*`, with mode-600 `auth.json` and file-only credential storage. Only non-secret scalar
163
+ preferences (`model`, `model_reasoning_effort`, `service_tier`, `personality`,
164
+ `model_context_window`, `model_auto_compact_token_limit`) from the top level of
165
+ `config.toml` are copied. Hooks, custom providers, environment settings and other
166
+ controller-local configuration are not copied. The home is removed on completion,
167
+ failure, cancellation and SSH stdin closure. Stop a detached run by sending
168
+ SIGTERM to the controller PID returned at launch. Uncatchable worker termination
169
+ can leave a home behind; the next launch sweeps homes whose worker lock is no
170
+ longer held. No persistent worker authentication is installed or refreshed.
171
+
172
+ A profile occupied by a remote run is busy for local and remote runs using the
173
+ same controller through the run registry. The authentication lock is held only
174
+ while refreshing and validating the credential snapshot, then released before SSH
175
+ launch. Scheduled `cdx auth refresh` and quota probes can acquire it during a long
176
+ mission; they no longer see a mission-long `locked`/`auth_refresh_indeterminate`
177
+ result. A probe overlapping the short snapshot can still report contention.
178
+ Controller renewal does not extend the worker's copied access token; quota remains
179
+ one controller-side account snapshot, never a worker copy. Multiple independent controllers sharing copied OAuth profiles are not
180
+ supported. Costs and budget checks use measured worker usage and observed rollout
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
190
+ `remote_credential_expired`, requiring a new controller launch.
191
+
192
+ The empty-refresh-token behavior was measured with Codex CLI 0.166.0. Real-host
193
+ acceptance should verify that the installed matching CLI does not attempt a
194
+ refresh with the temporary home, that the controller token chain stays unchanged
195
+ for a fresh credential, and that cleanup works after disconnect and SIGTERM.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.2
1
+ 0.25.4
@@ -0,0 +1,20 @@
1
+ # CDX Manager 0.25.3
2
+
3
+ Release date: 2026-11-01
4
+
5
+ This patch release adds remote Codex execution over SSH and strengthens credential isolation for local and remote sandboxes.
6
+
7
+ ## Features
8
+
9
+ - Run Codex on a Linux SSH worker with temporary credentials while keeping profiles, quota, history, costs, and run tracking on the controller. Support detached execution and clean up the temporary worker home after the run. (#78)
10
+
11
+ ## Fixes and security
12
+
13
+ - Keep rotating refresh tokens on the controller and out of local and remote sandboxes. Use temporary access-token snapshots on verified memory-backed storage, refresh and validate credentials before launch, and fail explicitly when a snapshot expires. (#81)
14
+ - Upgrade cryptography and pytest to address security alerts while preserving Python 3.9 compatibility through a version-specific pytest requirement. (#80)
15
+ - Fix remote bootstrapping through Windows SSH shells and WSL without exceeding the command-line length limit. Check isolation prerequisites before sending credentials and refuse unsafe isolation without an unisolated fallback. (#79)
16
+
17
+ ## Maintenance
18
+
19
+ - Record tray companion checksums for v0.25.2. (`4f810c3801418bc0ad8a480333cc0bdfda39383e`)
20
+ - Record release archive checksums for v0.25.2. (`b33eba1894d9cc68d395133848beccae38589a68`)
@@ -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`)
@@ -1,7 +1,42 @@
1
1
  # Unreleased
2
2
 
3
+ ## Dependency security
4
+
5
+ - 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.
6
+ - Require pytest >=9.0.3 in the development extra on Python >=3.10; retain the existing 9.2.0 lock entry. pytest is not a runtime dependency. Python 3.9 still uses 8.4.2, the last compatible release, and its medium-severity alert remains unresolved pending an upstream backport or a decision to drop Python 3.9 development support.
7
+ - Check lock consistency and run the CI test matrix against locked development dependencies without upgrading unrelated packages.
8
+
3
9
  ## Run costs and budgets
4
10
 
5
11
  - Add nullable `cost_usd` to JSON history entries using the same observed usage, model pricing, and price overrides as stats.
6
12
  - 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.
7
13
  - 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
+
15
+ ## Remote run cost observations
16
+
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+ - 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.
21
+
22
+ ## Remote Codex runs
23
+
24
+ - Add `cdx run --remote SSH_TARGET` with controller-owned supervision, registry, history, output, costs, budgets and detached execution.
25
+ - Refresh under the controller authentication lock at the seven-day/24-hour thresholds, then send a temporary OAuth credential over SSH stdin with an empty refresh token. Refuse rotating credentials, other providers and CLI version mismatches.
26
+ - Keep worker credentials in a private memory-backed home, clean up after success, failure and stop, and sweep dead homes on the next launch. Keep agent stdin separate from bootstrap input and report credential expiration without retrying.
27
+ - Cover transport and credential isolation, thresholds, cleanup, sweep, unreachable workers, provider refusal and registry reports with a fake SSH worker.
28
+
29
+ ## Remote worker transport and isolation preflight
30
+
31
+ - Add `--remote-prefix` for Windows SSH landings such as `wsl.exe -d Ubuntu --`, including detached runs. Keep the POSIX default and use a short double-quoted bootstrap on argv; send framed worker/sandbox source and then credentials/payload over stdin.
32
+ - Probe the local bubblewrap/pasta/nftables isolation mechanism on the worker with dummy credentials, including namespace/network startup. Refuse missing dependencies or setup failures without falling back to an unisolated run.
33
+ - Enable remote isolated Codex execution after a successful isolation probe, following the operator decision of 2026-11-01. Agent tools can read the copied access token (up to ten days of validity); missing dependencies still refuse without fallback.
34
+ - Release the controller authentication lock after preparing the credential snapshot so scheduled authentication refresh can run during long remote missions. Keep profile mission exclusion in the run registry and the worker credential's original expiry.
35
+ - Test cmd.exe length/quoting enforcement, Windows prefixes, large stdin payloads, POSIX transport, sanitized credential snapshots, sandbox construction/dependencies and concurrent scheduled refresh with synthetic credentials.
36
+
37
+ ## Isolated Codex credentials
38
+
39
+ - Replace the local full-profile auth mount with a mode-600 run-private snapshot on verified memory-backed storage, containing an empty refresh token. Refresh on the controller under the auth lock at the existing seven-day/24-hour thresholds before launch.
40
+ - Keep snapshots independent of controller refresh during long missions, prohibit authentication writeback, enforce their original expiry, and remove temporary storage after success, failure and stop. Non-isolated local authentication remains unchanged.
41
+ - Reject rotating credentials at the sandbox boundary. Test snapshot contents, permissions, cleanup, refresh lock ownership, concurrent controller refresh, remote isolation through fake SSH and missing dependency refusal.
42
+ - The separately maintained remote-ops `scripts/workspace_sandbox.py` requires the same credential guard and run-private lifecycle integration; it is not shipped by this repository.
@@ -705,6 +705,28 @@
705
705
  "x86_64-pc-windows-msvc": "183ef3664489888a356ad9a6096b68c913d433c513d6d10e9890e65667e1e417",
706
706
  "x86_64-unknown-linux-musl": "5660cfd08c0580db43dee5664fe95fc4cd528a6495c40167e34c122d3c4fcd4a"
707
707
  }
708
+ },
709
+ "v0.25.2": {
710
+ "github_tarball_sha256": "8ed02d4ce31ca6aa9669396675ee1d5b6669e80f33418c00c2a210306ea19e0d",
711
+ "github_zip_sha256": "0e8770a0e18c6c99bd5d845c8c54d90eb33fbf71720be89d87396eaee1632cfe",
712
+ "tray_assets": {
713
+ "aarch64-apple-darwin": "3c0cc113b70670e0f1bd9eff4589cddd8a5e3f76d7b53d7a1d66a2529f5e6e84",
714
+ "aarch64-unknown-linux-musl": "ff978672276fdfa8f22af8d3dadb63e0d26fa40c6f10d48f834073b08c620efd",
715
+ "x86_64-apple-darwin": "42323cbce340238ada8d986192d198a82e37d5958fb25b56cbaf8dba347da05c",
716
+ "x86_64-pc-windows-msvc": "ffcd4deb8b3843771659d2c6b92b003822b288329ddcf3b9a13f0f9820fa3df1",
717
+ "x86_64-unknown-linux-musl": "2cefeea0aad5bb0acfcbc99146ffacc5c5da9386f8338e4a01541e97ccf71e07"
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
+ }
708
730
  }
709
731
  }
710
732
  }
package/docs/cli.md CHANGED
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.25.2",
3
+ "version": "0.25.4",
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,10 +4,10 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.25.2"
7
+ version = "0.25.4"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
- requires-python = ">=3.9"
10
+ requires-python = ">=3.9.2"
11
11
  license = "MIT"
12
12
  authors = [
13
13
  { name = "Alexandre Agostini" }
@@ -26,11 +26,13 @@ classifiers = [
26
26
  "Topic :: Software Development",
27
27
  "Topic :: Terminals",
28
28
  ]
29
- dependencies = ["cryptography>=42"]
29
+ dependencies = ["cryptography>=50.0.0"]
30
30
 
31
31
  [project.optional-dependencies]
32
32
  dev = [
33
- "pytest>=8",
33
+ # pytest 9 requires Python 3.10; 8.4.2 is the last Python 3.9 release.
34
+ "pytest>=8.4.2; python_version < '3.10'",
35
+ "pytest>=9.0.3; python_version >= '3.10'",
34
36
  "pytest-cov>=5",
35
37
  "ruff>=0.6",
36
38
  ]
@@ -70,6 +72,7 @@ show_missing = true
70
72
  skip_empty = true
71
73
 
72
74
  [tool.ruff]
75
+ extend-exclude = [".jarvis-input"]
73
76
  target-version = "py39"
74
77
  line-length = 140
75
78
 
package/src/cli.py CHANGED
@@ -245,7 +245,7 @@ def _print_help(use_color=False):
245
245
  f" {_style('cdx reset <name> [--yes] [--json]', '36', use_color)}",
246
246
  f" {_style('cdx auth refresh <name|all> [--json]', '36', use_color)}",
247
247
  f" {_style('cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json', '36', use_color)}",
248
- f" {_style('cdx run [session] --cwd PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json', '36', use_color)}",
248
+ f" {_style('cdx run [session] [--remote SSH_TARGET] --cwd PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json', '36', use_color)}",
249
249
  f" {_style('cdx runs [--limit N] [--since 7d|today|DATE] [--running] --json', '36', use_color)}",
250
250
  f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
251
251
  f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
package/src/cli_args.py CHANGED
@@ -48,7 +48,7 @@ CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
48
48
  SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
49
49
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
50
50
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
51
- RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--budget USD] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json"
51
+ RUN_USAGE = "Usage: cdx run [session] [--remote SSH_TARGET] [--remote-prefix COMMAND] (--cwd|--dir) PATH [--project PATH] (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--budget USD] [--label TEXT] [--max-parallel N] [--continue-run RUN_ID] [--detach] [--failover] [--refresh] --json"
52
52
  RUN_JSON_REQUIRED = "cdx run: --json is required."
53
53
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
54
54
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
@@ -546,6 +546,13 @@ def cdx_schema():
546
546
  "run": [
547
547
  "invalid_cwd",
548
548
  "session_disabled",
549
+ "session_busy",
550
+ "auth_lock_failed",
551
+ "remote_unavailable",
552
+ "remote_version_mismatch",
553
+ "remote_provider_unsupported",
554
+ "remote_credential_expired",
555
+ "remote_isolation_unavailable",
549
556
  "no_suitable_session",
550
557
  "provider_cli_not_found",
551
558
  "provider_start_failed",
@@ -673,6 +680,8 @@ def _parse_max_parallel(value):
673
680
 
674
681
  def _parse_run_args(args):
675
682
  parsed = _parse_run_flag_args(args, {
683
+ "--remote": {"key": "remote", "type": "str", "default": None},
684
+ "--remote-prefix": {"key": "remote_prefix", "type": "str", "default": None},
676
685
  "--project": {"key": "project", "type": "str", "default": None},
677
686
  "--cwd": {"key": "cwd", "type": "str", "default": None},
678
687
  "--dir": {"key": "cwd", "type": "str", "default": None},
@@ -732,9 +741,17 @@ def _parse_run_args(args):
732
741
  if parsed["continue_run"] is not None and not parsed["continue_run"].strip():
733
742
  raise CdxArgumentError("cdx run: --continue-run requires a nonempty run id.",
734
743
  code=ARG_CODE_INVALID_VALUE, arguments=["--continue-run"])
744
+ if parsed["remote"] is not None and not parsed["remote"].strip():
745
+ raise CdxArgumentError("cdx run: --remote requires a nonempty SSH target.",
746
+ code=ARG_CODE_INVALID_VALUE, arguments=["--remote"])
747
+ if parsed["remote_prefix"] is not None and (not parsed["remote"] or not parsed["remote_prefix"].strip()):
748
+ raise CdxArgumentError("cdx run: --remote-prefix requires --remote and a nonempty command.",
749
+ code=ARG_CODE_INVALID_VALUE, arguments=["--remote-prefix"])
735
750
  effort = _parse_run_effort(parsed["reasoning_effort"], parsed["power"])
736
751
  return {
737
752
  "name": name,
753
+ "remote": parsed["remote"],
754
+ "remote_prefix": parsed["remote_prefix"],
738
755
  "provider": parsed["provider"],
739
756
  "cwd": parsed["cwd"],
740
757
  "project": parsed["project"],
@@ -18,7 +18,7 @@ CODEX_AUTH_LOCK_NAME = ".cdx-auth.lock"
18
18
 
19
19
 
20
20
  @contextlib.contextmanager
21
- def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_interval_seconds=0.05):
21
+ def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_interval_seconds=0.05, strict=False):
22
22
  """Serialize codex token refreshes per CODEX_HOME.
23
23
 
24
24
  Codex rotates its OAuth refresh_token on refresh and invalidates the old
@@ -28,6 +28,8 @@ def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_inter
28
28
  the lock is held, False when a non-blocking acquire found it already taken.
29
29
  ponytail: POSIX flock only; on Windows (no fcntl) this is a no-op.
30
30
  """
31
+ if strict and (not auth_home or fcntl is None):
32
+ raise OSError("A POSIX authentication lock is required")
31
33
  if not auth_home or fcntl is None:
32
34
  yield True
33
35
  return
@@ -35,6 +37,8 @@ def codex_auth_lock(auth_home, blocking=False, timeout_seconds=None, retry_inter
35
37
  os.makedirs(auth_home, exist_ok=True)
36
38
  handle = open(os.path.join(auth_home, CODEX_AUTH_LOCK_NAME), "w")
37
39
  except OSError:
40
+ if strict:
41
+ raise
38
42
  yield True
39
43
  return
40
44
  deadline = None if timeout_seconds is None else time.monotonic() + timeout_seconds
@@ -48,6 +48,7 @@ from ..provider_runtime import (
48
48
  _run_headless_provider_command,
49
49
  with_requested_conversation,
50
50
  )
51
+ from ..remote_run import preflight, run_remote
51
52
  from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
52
53
  from ..run_failover import (
53
54
  CAPACITY_RETRY_DELAYS,
@@ -203,6 +204,8 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
203
204
  # from the package root, so a relative "--cwd ." would resolve there.
204
205
  argv = ["run", session_name, "--cwd", cwd, "--prompt-file", prompt_path]
205
206
  for flag, key in (
207
+ ("--remote", "remote"),
208
+ ("--remote-prefix", "remote_prefix"),
206
209
  ("--project", "project"),
207
210
  ("--budget", "budget"),
208
211
  ("--continue-run", "continue_run"),
@@ -546,7 +549,7 @@ def _env_max_runs(ctx):
546
549
 
547
550
 
548
551
  def _observe_headless_usage(session, run_info, baseline=None):
549
- isolated = (session.get("launch") or {}).get("isolate_workspace")
552
+ isolated = bool(run_info.get("remote")) or (session.get("launch") or {}).get("isolate_workspace")
550
553
  evidence = run_info.get("usage_evidence")
551
554
  observed_models = []
552
555
  usage, model = extract_run_observation(session.get("provider"), run_info.get("stdout_path"),
@@ -581,7 +584,7 @@ def _observe_headless_usage(session, run_info, baseline=None):
581
584
 
582
585
  def _budget_observation(session, paths, baseline):
583
586
  observation = _observe_headless_usage(session, paths, baseline if session["provider"] == "codex" else None)
584
- if (session.get("launch") or {}).get("isolate_workspace"):
587
+ if paths.get("remote") or (session.get("launch") or {}).get("isolate_workspace"):
585
588
  return observation
586
589
  identifier = (extract_run_conversation_id(session["provider"], paths.get("stdout_path"))
587
590
  or ((session.get("conversation") or {}).get("id")
@@ -710,8 +713,18 @@ def handle_run(rest, ctx):
710
713
  session = selected["session"]
711
714
  selection_row = selected.get("row") or {}
712
715
 
716
+ remote_version = None
717
+ if parsed.get("remote"):
718
+ if session["provider"] != "codex":
719
+ raise CdxError("--remote is only available for Codex profiles.", code="remote_provider_unsupported")
720
+ if parsed.get("continue_run"):
721
+ raise CdxError("--remote requires a fresh run without --continue-run.")
722
+ remote_version = preflight(
723
+ parsed["remote"], {**os.environ, **(ctx.get("env") or {})},
724
+ remote_prefix=parsed.get("remote_prefix"), isolate_workspace=parsed.get("isolate_workspace", False),
725
+ cwd=parsed["cwd"], network=parsed.get("network_access", False), writable=parsed.get("permission") != "review")
713
726
  cwd = os.path.abspath(parsed["cwd"])
714
- if not os.path.isdir(cwd):
727
+ if not parsed.get("remote") and not os.path.isdir(cwd):
715
728
  raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
716
729
  from ..project_identity import resolve_project
717
730
 
@@ -753,17 +766,18 @@ def handle_run(rest, ctx):
753
766
  **launch_updates,
754
767
  },
755
768
  }
756
- _ensure_session_authentication(
757
- run_session,
758
- ctx["service"],
759
- spawn=ctx.get("spawn"),
760
- spawn_sync=ctx.get("spawn_sync"),
761
- env_override=ctx.get("env"),
762
- stdin_is_tty=False,
763
- behavior="launch",
764
- signal_emitter=ctx.get("signal_emitter"),
765
- trust_local_credentials=False,
766
- )
769
+ if not parsed.get("remote"):
770
+ _ensure_session_authentication(
771
+ run_session,
772
+ ctx["service"],
773
+ spawn=ctx.get("spawn"),
774
+ spawn_sync=ctx.get("spawn_sync"),
775
+ env_override=ctx.get("env"),
776
+ stdin_is_tty=False,
777
+ behavior="launch",
778
+ signal_emitter=ctx.get("signal_emitter"),
779
+ trust_local_credentials=False,
780
+ )
767
781
  registry = _run_registry(ctx)
768
782
  # A detached child is handed its parent's run_id so both agree on the
769
783
  # identity the launch payload already reported to the caller.
@@ -788,6 +802,7 @@ def handle_run(rest, ctx):
788
802
  },
789
803
  label=parsed.get("label"),
790
804
  max_parallel=max_parallel,
805
+ remote=parsed.get("remote"),
791
806
  parent_run_id=parsed.get("continue_run"),
792
807
  conversation_id=(run_session.get("conversation") or {}).get("id") if run_session["provider"] == "claude" or parsed.get("continue_run") else None,
793
808
  launch=run_session.get("launch"),
@@ -870,7 +885,11 @@ def handle_run(rest, ctx):
870
885
  budget_observation = observation
871
886
  return attempt_cost is not None and already_spent + attempt_cost >= parsed["budget"]
872
887
 
873
- run_info = _run_headless_provider_command(
888
+ runner = _run_headless_provider_command
889
+ if parsed.get("remote"):
890
+ def runner(*args, **kwargs):
891
+ return run_remote(*args, target=parsed["remote"], version=remote_version, remote_prefix=parsed.get("remote_prefix"), **kwargs)
892
+ run_info = runner(
874
893
  run_session,
875
894
  cwd=cwd,
876
895
  env_override=ctx.get("env"),
@@ -918,7 +937,7 @@ def handle_run(rest, ctx):
918
937
  **run_info,
919
938
  **project,
920
939
  })
921
- if run_info.get("budget_exceeded") or run_info.get("budget_unverifiable") or parsed.get("continue_run"):
940
+ if parsed.get("remote") or run_info.get("budget_exceeded") or run_info.get("budget_unverifiable") or parsed.get("continue_run"):
922
941
  break
923
942
  if looks_model_at_capacity(run_session.get("provider"), run_info):
924
943
  event = {"reason": "model_at_capacity", "attempt": attempt,
@@ -1208,16 +1208,26 @@ def _read_permission_denials(stdout_path):
1208
1208
 
1209
1209
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
1210
1210
  timeout_seconds=None, spawn=None, run_id=None, budget_check=None, attempt=1):
1211
- import tempfile
1211
+ from .remote_run import remote_auth_lock, temporary_auth
1212
+ from .workspace_sandbox import private_auth_home
1212
1213
 
1213
1214
  isolated_codex = session.get("provider") == PROVIDER_CODEX and (session.get("launch") or {}).get("isolate_workspace")
1214
1215
  if isolated_codex:
1215
- # Volatile storage only: raw rollouts must not survive closeout, including
1216
- # cancellation/start failures. Only this attempt's sessions are mounted.
1217
- with tempfile.TemporaryDirectory(prefix="cdx-observation-", dir="/dev/shm" if sys.platform.startswith("linux") else None) as directory:
1218
- os.mkdir(os.path.join(directory, "sessions"), mode=0o700)
1219
- return _run_headless_provider_attempt(session, cwd, env_override, initial_prompt,
1220
- timeout_seconds, spawn, run_id, budget_check, directory, attempt)
1216
+ handlers = _route_termination_to_interrupt()
1217
+ try:
1218
+ with remote_auth_lock(_get_auth_home(session)):
1219
+ auth = temporary_auth(_get_auth_home(session))
1220
+ with private_auth_home(auth) as directory:
1221
+ return _run_headless_provider_attempt(session, cwd, env_override, initial_prompt,
1222
+ timeout_seconds, spawn, run_id, budget_check, directory, attempt)
1223
+ except KeyboardInterrupt as cause:
1224
+ error = CdxError('Isolated Codex run was cancelled.', 130)
1225
+ error.cancelled = True
1226
+ raise error from cause
1227
+ except (OSError, RuntimeError) as error:
1228
+ raise CdxError(str(error)) from error
1229
+ finally:
1230
+ _restore_signal_handlers(handlers)
1221
1231
  return _run_headless_provider_attempt(session, cwd, env_override, initial_prompt,
1222
1232
  timeout_seconds, spawn, run_id, budget_check, attempt=attempt)
1223
1233
 
@@ -1266,7 +1276,7 @@ def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial
1266
1276
  [spec["command"], *spec["args"]], Path(cwd),
1267
1277
  network=launch.get("network_access", False),
1268
1278
  writable=launch.get("permission") != "review",
1269
- source=spec["options"]["env"], auth_home=_get_auth_home(session),
1279
+ source=spec["options"]["env"], auth_home=observation_home or _get_auth_home(session),
1270
1280
  provider=session.get("provider"),
1271
1281
  **({"observation_home": os.path.join(observation_home, "sessions")} if observation_home else {}))
1272
1282
  except (OSError, RuntimeError) as error:
@@ -1291,6 +1301,12 @@ def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial
1291
1301
  timed_out = False
1292
1302
  cancelled = False
1293
1303
  budget_exceeded = False
1304
+ credential_expired = False
1305
+ expires_at = None
1306
+ if observation_home:
1307
+ from .remote_run import token_expiry
1308
+ with open(os.path.join(observation_home, 'auth.json')) as handle:
1309
+ expires_at = token_expiry(json.load(handle)['tokens']['access_token'])
1294
1310
  with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
1295
1311
  open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
1296
1312
  options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
@@ -1319,11 +1335,15 @@ def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial
1319
1335
  # this supervisor and keep its run "running" forever.
1320
1336
  previous_handlers = _route_termination_to_interrupt()
1321
1337
  try:
1322
- if budget_check is not None:
1338
+ if budget_check is not None or expires_at is not None:
1323
1339
  deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
1324
1340
  while True:
1325
1341
  snapshot()
1326
- if budget_check(paths):
1342
+ if expires_at is not None and time.time() >= expires_at:
1343
+ credential_expired = True
1344
+ _terminate_child_tree(child)
1345
+ break
1346
+ if budget_check and budget_check(paths):
1327
1347
  budget_exceeded = True
1328
1348
  _terminate_child_tree(child)
1329
1349
  break
@@ -1333,7 +1353,7 @@ def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial
1333
1353
  try:
1334
1354
  child.wait(timeout=min(0.25, remaining) if remaining is not None else 0.25)
1335
1355
  snapshot()
1336
- budget_exceeded = budget_check(paths)
1356
+ budget_exceeded = bool(budget_check and budget_check(paths))
1337
1357
  break
1338
1358
  except subprocess.TimeoutExpired:
1339
1359
  continue
@@ -1357,6 +1377,11 @@ def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial
1357
1377
  snapshot()
1358
1378
  _restore_signal_handlers(previous_handlers)
1359
1379
 
1380
+ if credential_expired or (expires_at is not None and time.time() >= expires_at):
1381
+ _combine_headless_transcript(paths)
1382
+ error = CdxError('Isolated temporary credential expired; start a new run from the controller.', code='remote_credential_expired')
1383
+ error.run_info = _headless_run_info(paths, spec, start_time, child.returncode)
1384
+ raise error
1360
1385
  if cancelled:
1361
1386
  _combine_headless_transcript(paths)
1362
1387
  returncode = getattr(child, "returncode", None)
@@ -0,0 +1,272 @@
1
+ """Controller-side temporary Codex credentials and SSH transport."""
2
+
3
+ import base64
4
+ import json
5
+ import math
6
+ import os
7
+ import pathlib
8
+ import re
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from contextlib import contextmanager
13
+ from datetime import datetime, timezone
14
+
15
+ from .codex_usage import _request_codex_app_server, codex_auth_lock
16
+ from .errors import CdxError
17
+
18
+
19
+ def token_expiry(token):
20
+ try:
21
+ part = token.split('.')[1]
22
+ expiry = float(json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))['exp'])
23
+ if not math.isfinite(expiry) or expiry <= 0:
24
+ raise ValueError('Invalid expiration')
25
+ return expiry
26
+ except (ValueError, KeyError, IndexError, TypeError) as error:
27
+ raise CdxError('Cannot determine remote access token expiration; log in on the controller.') from error
28
+
29
+
30
+ def needs_refresh(auth, now):
31
+ try:
32
+ refreshed = datetime.fromisoformat(auth['last_refresh'].replace('Z', '+00:00')).timestamp()
33
+ except (ValueError, KeyError, TypeError):
34
+ return True
35
+ return now - refreshed > 7 * 86400 or token_expiry(auth['tokens']['access_token']) - now < 86400
36
+
37
+
38
+ def refresh_auth(home):
39
+ result = _request_codex_app_server(home, 'account/read', {'refreshToken': True}, 'refresh_failed', timeout=30)
40
+ if not result.get('ok'):
41
+ raise CdxError('Controller Codex credential refresh failed; log in on the controller.')
42
+
43
+
44
+ def temporary_auth(home, now=None):
45
+ now = time.time() if now is None else now
46
+ try:
47
+ auth = json.loads((pathlib.Path(home) / 'auth.json').read_text())
48
+ if needs_refresh(auth, now):
49
+ refresh_auth(home)
50
+ auth = json.loads((pathlib.Path(home) / 'auth.json').read_text())
51
+ if needs_refresh(auth, now):
52
+ raise CdxError('Controller refresh did not produce a fresh remote credential.')
53
+ tokens = {key: auth['tokens'][key] for key in ('access_token', 'id_token', 'account_id')}
54
+ tokens['refresh_token'] = ''
55
+ result = {'tokens': tokens, 'last_refresh': auth['last_refresh']}
56
+ rotating = auth['tokens'].get('refresh_token')
57
+ if result['tokens']['refresh_token'] != '' or (rotating and rotating in json.dumps(result)):
58
+ raise CdxError('Refusing to send a refresh token to a worker.')
59
+ return result
60
+ except (OSError, ValueError, KeyError, TypeError) as error:
61
+ raise CdxError('Cannot read controller Codex OAuth credentials; log in on the controller.') from error
62
+
63
+
64
+ def nonsecret_config(home):
65
+ # Copy only known scalar preferences. Provider URLs, hooks, env and arbitrary
66
+ # config entries can contain secrets or refer to controller-only programs.
67
+ path = pathlib.Path(home) / 'config.toml'
68
+ if not path.exists():
69
+ return ''
70
+ allowed = {'model', 'model_reasoning_effort', 'service_tier', 'personality',
71
+ 'model_context_window', 'model_auto_compact_token_limit'}
72
+ lines = []
73
+ for line in path.read_text().splitlines():
74
+ if line.lstrip().startswith('['):
75
+ break
76
+ key, sep, value = line.partition('=')
77
+ if sep and key.strip() in allowed and re.fullmatch(r'\s*("[a-zA-Z0-9_. /-]+"|[0-9]+)\s*', value):
78
+ lines.append(line)
79
+ return '\n'.join(lines) + '\n'
80
+
81
+
82
+ def worker_input():
83
+ # One JSON line frames the source, leaving the next line for the payload.
84
+ directory = pathlib.Path(__file__).parent
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'
91
+ source += (directory / 'remote_worker.py').read_text()
92
+ return json.dumps(source).encode() + b'\n'
93
+
94
+
95
+ def ssh_command(target, mode, remote_prefix=None):
96
+ if not re.fullmatch(r'[a-zA-Z0-9_@.:[\]-]+', target) or target.startswith('-'):
97
+ raise CdxError('Invalid --remote SSH target; use a host alias or user@host.')
98
+ prefix = (remote_prefix or '').strip()
99
+ if prefix and not re.fullmatch(r'[a-zA-Z0-9_./:=+ -]+', prefix):
100
+ raise CdxError('Invalid --remote-prefix; use a plain command prefix such as wsl.exe -d Ubuntu --.')
101
+ if mode not in ('probe', 'run'):
102
+ raise CdxError('Invalid remote worker mode.')
103
+ # Double quotes work in both POSIX sh and cmd.exe. The bootstrap contains
104
+ # no embedded double quotes, shell expansions, or worker source in argv.
105
+ command = (prefix + ' ' if prefix else '') + 'python3 -u -c "import sys,json;exec(json.loads(sys.stdin.buffer.readline()))" ' + mode
106
+ if len(command) > 8000:
107
+ raise CdxError('Remote command prefix exceeds the Windows command-line limit.')
108
+ return ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'ConnectionAttempts=1',
109
+ '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=1', target, command]
110
+
111
+
112
+ def preflight(target, env=None, *, remote_prefix=None, isolate_workspace=False, cwd=None, network=False, writable=True):
113
+ payload = {'isolate_workspace': isolate_workspace, 'cwd': cwd, 'network': network, 'writable': writable}
114
+ try:
115
+ local = subprocess.check_output(['codex', '--version'], env=env, stderr=subprocess.DEVNULL, timeout=10).decode().strip()
116
+ result = subprocess.run(ssh_command(target, 'probe', remote_prefix), env=env,
117
+ input=worker_input() + json.dumps(payload).encode() + b'\n',
118
+ capture_output=True, timeout=30)
119
+ except (OSError, subprocess.TimeoutExpired) as error:
120
+ raise CdxError('Remote worker unreachable or Codex CLI unavailable.', code='remote_unavailable') from error
121
+ if result.returncode:
122
+ message = ('Remote workspace isolation unavailable; requires bubblewrap, pasta, nftables, working namespaces and a valid checkout.'
123
+ if isolate_workspace else 'Remote worker unreachable or missing Python 3, Codex or memory-backed /dev/shm.')
124
+ raise CdxError(message, code='remote_unavailable')
125
+ if local != result.stdout.decode().strip():
126
+ raise CdxError('Remote Codex CLI version mismatch; install the controller version on the worker.', code='remote_version_mismatch')
127
+ return local
128
+
129
+
130
+ @contextmanager
131
+ def remote_auth_lock(home):
132
+ try:
133
+ with codex_auth_lock(home, strict=True) as acquired:
134
+ if not acquired:
135
+ raise CdxError('Codex profile is busy on the controller or a worker.', code='session_busy')
136
+ yield
137
+ except OSError as error:
138
+ raise CdxError('Unable to hold the controller authentication lock.', code='auth_lock_failed') from error
139
+
140
+
141
+ def run_remote(session, target, version, cwd=None, env_override=None, initial_prompt=None,
142
+ timeout_seconds=None, run_id=None, attempt=1, budget_check=None, remote_prefix=None, **_):
143
+ from .provider_runtime import (
144
+ _build_headless_launch_spec,
145
+ _combine_headless_transcript,
146
+ _headless_artifact_paths,
147
+ _headless_run_info,
148
+ _restore_signal_handlers,
149
+ _route_termination_to_interrupt,
150
+ )
151
+
152
+ home = session['authHome']
153
+ with remote_auth_lock(home):
154
+ auth = temporary_auth(home)
155
+ if auth['tokens'].get('refresh_token') != '':
156
+ raise CdxError('Refusing to send a refresh token to a worker.')
157
+ spec = _build_headless_launch_spec(session, cwd, env_override, initial_prompt)
158
+ if (session.get('launch') or {}).get('network_access'):
159
+ index = spec['args'].index('--') if '--' in spec['args'] else len(spec['args'])
160
+ spec['args'][index:index] = ['-c', 'sandbox_workspace_write.network_access=true']
161
+ config = nonsecret_config(home)
162
+ launch_text = json.dumps(spec['args']) + config
163
+ if any(value and value in launch_text for value in auth['tokens'].values()):
164
+ raise CdxError('Refusing a credential embedded in the remote prompt or configuration.')
165
+ payload = {'auth': auth, 'config': config, 'args': spec['args'], 'cwd': cwd,
166
+ 'version': version, 'expires_at': token_expiry(auth['tokens']['access_token']),
167
+ 'isolate_workspace': bool((session.get('launch') or {}).get('isolate_workspace')),
168
+ 'network': bool((session.get('launch') or {}).get('network_access')),
169
+ 'writable': (session.get('launch') or {}).get('permission') != 'review'}
170
+ rotating = json.loads((pathlib.Path(home) / 'auth.json').read_text())['tokens'].get('refresh_token')
171
+ if rotating and rotating in json.dumps(payload):
172
+ raise CdxError('Refusing to send a refresh token to a worker.')
173
+ paths = _headless_artifact_paths(session, run_id, attempt)
174
+ paths['remote'] = target
175
+ start = datetime.now(timezone.utc)
176
+ child = receiver = out = err = None
177
+ cancelled = timed_out = budget_exceeded = credential_expired = False
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
+
200
+ try:
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)
227
+ except KeyboardInterrupt:
228
+ cancelled = True
229
+ except (OSError, ValueError) as error:
230
+ raise CdxError('Remote SSH transport failed.', code='remote_unavailable') from error
231
+ finally:
232
+ if child:
233
+ # EOF makes the worker stop its process group and remove the home.
234
+ try:
235
+ child.stdin.close()
236
+ except (OSError, BrokenPipeError):
237
+ pass
238
+ try:
239
+ child.wait(timeout=10)
240
+ except subprocess.TimeoutExpired:
241
+ child.terminate()
242
+ try:
243
+ child.wait(timeout=2)
244
+ except subprocess.TimeoutExpired:
245
+ child.kill()
246
+ child.wait()
247
+ if receiver:
248
+ receiver.join()
249
+ if out:
250
+ out.close()
251
+ if err:
252
+ err.close()
253
+ _restore_signal_handlers(handlers)
254
+ _combine_headless_transcript(paths)
255
+ info = {**_headless_run_info(paths, spec, start, child.returncode), 'remote': target,
256
+ 'timed_out': timed_out, 'budget_exceeded': budget_exceeded}
257
+ if cancelled:
258
+ error = CdxError('Remote run was cancelled.', 130)
259
+ error.cancelled = True
260
+ error.run_info = {**info, 'returncode': 130}
261
+ raise error
262
+ if credential_expired or time.time() >= payload['expires_at'] or child.returncode == 123:
263
+ error = CdxError('Remote temporary credential expired; start a new run from the controller.', code='remote_credential_expired')
264
+ error.run_info = info
265
+ raise error
266
+ if child.returncode == 255 and not (timed_out or budget_exceeded):
267
+ error = CdxError('Remote SSH connection failed or was lost.', code='remote_unavailable')
268
+ error.run_info = info
269
+ raise error
270
+ if timed_out or budget_exceeded:
271
+ info['returncode'] = 125 if budget_exceeded else 124
272
+ return info
@@ -0,0 +1,248 @@
1
+ """Self-contained Linux worker, framed over SSH stdin before the run payload."""
2
+
3
+ import fcntl
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import shutil
8
+ import signal
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ import types
16
+ from datetime import datetime, timezone
17
+
18
+ # Supplied by the controller before this module is executed.
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']
35
+
36
+
37
+ def version():
38
+ return subprocess.check_output(["codex", "--version"], stderr=subprocess.DEVNULL, timeout=10).decode().strip()
39
+
40
+
41
+ def storage():
42
+ mounts = [row.split() for row in pathlib.Path('/proc/mounts').read_text().splitlines()]
43
+ location = pathlib.Path('/dev/shm').resolve()
44
+ enclosing = [row for row in mounts if pathlib.Path(row[1]) == location or pathlib.Path(row[1]) in location.parents]
45
+ if not enclosing or max(enclosing, key=lambda row: len(row[1]))[2] != 'tmpfs':
46
+ raise RuntimeError("Remote execution requires memory-backed /dev/shm")
47
+ root = pathlib.Path('/dev/shm') / f'cdx-remote-{os.getuid()}'
48
+ root.mkdir(mode=0o700, exist_ok=True)
49
+ info = root.lstat()
50
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
51
+ raise RuntimeError("Unsafe remote credential directory")
52
+ return root
53
+
54
+
55
+ def allocate(root):
56
+ # Serialize sweep and allocation so another launcher cannot sweep a new home.
57
+ with open(root / '.sweep', 'a') as sweep:
58
+ fcntl.flock(sweep, fcntl.LOCK_EX)
59
+ for path in root.glob('run-*'):
60
+ if path.is_symlink() or not path.is_dir():
61
+ continue
62
+ try:
63
+ with open(path / '.live', 'a') as live:
64
+ fcntl.flock(live, fcntl.LOCK_EX | fcntl.LOCK_NB)
65
+ shutil.rmtree(path)
66
+ except (BlockingIOError, FileNotFoundError):
67
+ continue
68
+ home = pathlib.Path(tempfile.mkdtemp(prefix='run-', dir=root))
69
+ live = open(home / '.live', 'a')
70
+ fcntl.flock(live, fcntl.LOCK_EX)
71
+ return home, live
72
+
73
+
74
+ def isolation_probe_command(payload, home, env):
75
+ # Execute exactly the local confinement implementation, shipped over stdin.
76
+ namespace = {'__name__': 'cdx_workspace_sandbox'}
77
+ exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
78
+ return namespace['sandbox_command'](
79
+ ['codex', '--version'], pathlib.Path(payload['cwd']), network=payload['network'],
80
+ writable=payload['writable'], source=env, auth_home=str(home), provider='codex',
81
+ observation_home=str(home / 'sessions'))
82
+
83
+
84
+ def probe(root, payload):
85
+ if not payload.get('isolate_workspace'):
86
+ print(version())
87
+ return 0
88
+ # Exercise mounts, namespaces and the nft setup with a dummy home before
89
+ # the controller reads or sends a credential. Binary presence is insufficient.
90
+ with tempfile.TemporaryDirectory(prefix='probe-', dir=root) as directory:
91
+ home = pathlib.Path(directory)
92
+ (home / 'auth.json').write_text(json.dumps({'tokens': {'refresh_token': ''}}))
93
+ os.chmod(home / 'auth.json', 0o600)
94
+ (home / 'sessions').mkdir()
95
+ command, env = isolation_probe_command(payload, home, dict(os.environ))
96
+ result = subprocess.run(command, env=env, stdin=subprocess.DEVNULL,
97
+ capture_output=True, timeout=20)
98
+ if result.returncode:
99
+ raise RuntimeError('Remote workspace isolation unavailable')
100
+ print(result.stdout.decode().strip())
101
+ return 0
102
+
103
+
104
+ def main():
105
+ root = storage()
106
+ payload = json.loads(sys.stdin.buffer.readline())
107
+ if sys.argv[-1] == 'probe':
108
+ return probe(root, payload)
109
+ auth = payload['auth']
110
+ if auth['tokens'].get('refresh_token') != '':
111
+ raise RuntimeError('Refusing a rotating remote credential')
112
+ if version() != payload['version']:
113
+ raise RuntimeError('Remote Codex CLI version mismatch')
114
+ child = None
115
+ stopped = threading.Event()
116
+
117
+ def stop(*_):
118
+ stopped.set()
119
+ if child is not None and child.poll() is None:
120
+ try:
121
+ os.killpg(child.pid, signal.SIGTERM)
122
+ child.wait(timeout=3)
123
+ except subprocess.TimeoutExpired:
124
+ os.killpg(child.pid, signal.SIGKILL)
125
+ child.wait()
126
+ except ProcessLookupError:
127
+ pass
128
+
129
+ def disconnected():
130
+ while os.read(0, 1):
131
+ pass
132
+ stop()
133
+
134
+ signal.signal(signal.SIGTERM, stop)
135
+ signal.signal(signal.SIGHUP, stop)
136
+ signal.signal(signal.SIGINT, stop)
137
+ home, live = allocate(root)
138
+ try:
139
+ if stopped.is_set():
140
+ return 130
141
+ os.umask(0o077)
142
+ (home / 'auth.json').write_text(json.dumps(auth))
143
+ os.chmod(home / 'auth.json', 0o600)
144
+ (home / 'config.toml').write_text('cli_auth_credentials_store = "file"\n' + payload['config'])
145
+ env = {key: value for key, value in os.environ.items()
146
+ if key not in ('OPENAI_API_KEY', 'OPENAI_ACCESS_TOKEN', 'CODEX_ACCESS_TOKEN', 'CODEX_API_KEY', 'CODEX_HOME')}
147
+ env['CODEX_HOME'] = str(home)
148
+ command = ['codex', *payload['args']]
149
+ if payload.get('isolate_workspace'):
150
+ (home / 'sessions').mkdir(mode=0o700)
151
+ args = payload['args'][:]
152
+ while '-s' in args:
153
+ index = args.index('-s')
154
+ del args[index:index + 2]
155
+ if '--dangerously-bypass-approvals-and-sandbox' not in args:
156
+ index = args.index('--') if '--' in args else len(args)
157
+ args[index:index] = ['--dangerously-bypass-approvals-and-sandbox']
158
+ namespace = {'__name__': 'cdx_workspace_sandbox'}
159
+ exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
160
+ command, env = namespace['sandbox_command'](
161
+ ['codex', *args], pathlib.Path(payload['cwd']), network=payload['network'],
162
+ writable=payload['writable'], source=env, auth_home=str(home), provider='codex',
163
+ observation_home=str(home / 'sessions'))
164
+ started_at = datetime.now(timezone.utc).isoformat()
165
+ child = subprocess.Popen(command, cwd=payload['cwd'], env=env,
166
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
167
+ start_new_session=True)
168
+ if stopped.is_set():
169
+ stop()
170
+ threading.Thread(target=disconnected, daemon=True).start()
171
+ secrets = [value for value in auth['tokens'].values() if isinstance(value, str) and value]
172
+
173
+ output_lock = threading.Lock()
174
+ usage_reader, observation_reader = observation_runtime()
175
+ stdout_path = home / 'stdout.jsonl'
176
+ stdout_path.touch(mode=0o600)
177
+
178
+ def snapshot():
179
+ # Read the same exact-thread evidence as the local supervisor before
180
+ # volatile storage is removed. Only normalized metadata is exported.
181
+ with output_lock:
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)
204
+
205
+ def forward(source, target):
206
+ for line in iter(source.readline, b''):
207
+ for secret in secrets:
208
+ line = line.replace(secret.encode(), b'[REDACTED]')
209
+ with output_lock:
210
+ if target is sys.stdout:
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()
218
+
219
+ threads = [threading.Thread(target=forward, args=pair) for pair in
220
+ ((child.stdout, sys.stdout), (child.stderr, sys.stderr))]
221
+ for thread in threads:
222
+ thread.start()
223
+ while child.poll() is None:
224
+ try:
225
+ child.wait(timeout=0.25)
226
+ except subprocess.TimeoutExpired:
227
+ snapshot()
228
+ result = child.returncode
229
+ for thread in threads:
230
+ thread.join()
231
+ snapshot()
232
+ if time.time() >= payload['expires_at']:
233
+ print('Remote temporary credential expired; start a new run from the controller.', file=sys.stderr)
234
+ return 123
235
+ return 130 if stopped.is_set() else result
236
+ finally:
237
+ stop()
238
+ shutil.rmtree(home)
239
+ live.close()
240
+
241
+
242
+ if __name__ == '__main__':
243
+ try:
244
+ sys.exit(main())
245
+ except Exception:
246
+ # Never render exception values: input and provider errors may contain credentials.
247
+ print('Remote worker setup or execution failed; check Python, Codex, cwd, /dev/shm and isolation dependencies.', file=sys.stderr)
248
+ sys.exit(126)
@@ -169,6 +169,7 @@ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
169
169
  "ok": True,
170
170
  "action": "run",
171
171
  "launcher": "cdx",
172
+ **({"remote": parsed["remote"]} if parsed.get("remote") else {}),
172
173
  "project": parsed.get("project"),
173
174
  "project_source": parsed.get("project_source", "unknown"),
174
175
  "parent_run_id": parsed.get("continue_run"),
@@ -221,6 +222,7 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
221
222
  "ok": bool(ok),
222
223
  "action": "run",
223
224
  "launcher": "cdx",
225
+ **({"remote": parsed["remote"]} if parsed.get("remote") else {}),
224
226
  "project": parsed.get("project"),
225
227
  "project_source": parsed.get("project_source", "unknown"),
226
228
  "parent_run_id": parsed.get("continue_run"),
@@ -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)
@@ -244,15 +244,21 @@ class RunRegistry:
244
244
  _check_capacity(_read_registry(self.path), max_parallel)
245
245
 
246
246
  def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None,
247
- parent_run_id=None, conversation_id=None, launch=None, reasoning_effort=None, project=None, project_source="unknown"):
247
+ parent_run_id=None, conversation_id=None, launch=None, reasoning_effort=None, project=None, project_source="unknown", remote=None):
248
248
  with _registry_lock(self.path):
249
249
  data = _read_registry(self.path)
250
+ _refresh_stale_runs(data)
251
+ for run in data["runs"]:
252
+ if (run.get("run_id") != run_id and run.get("status") == "running"
253
+ and run.get("session") == session and (remote or run.get("remote"))):
254
+ raise CdxError(f"Session is busy on the controller or a worker: {session}", code="session_busy")
250
255
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
251
256
  if max_parallel:
252
257
  # Counted under the lock and without this run's own record, which a
253
258
  # detached child writes a second time under the same id.
254
259
  _check_capacity(data, max_parallel)
255
260
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
261
+ record["remote"] = remote
256
262
  record.update(parent_run_id=parent_run_id, conversation_id=conversation_id,
257
263
  launch=dict(launch or {}), reasoning_effort=reasoning_effort,
258
264
  project=project, project_source=project_source)
@@ -5,8 +5,11 @@ Provider authentication is the only credential deliberately passed to the provid
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import json
8
9
  import os
9
10
  import shutil
11
+ import tempfile
12
+ from contextlib import contextmanager
10
13
  from pathlib import Path
11
14
 
12
15
  CACHE = Path.home() / ".cdx" / "shared-cache"
@@ -47,6 +50,24 @@ sys.exit(status)
47
50
  """
48
51
 
49
52
 
53
+ @contextmanager
54
+ def private_auth_home(auth):
55
+ """Keep a sanitized credential in verified memory-backed, private storage."""
56
+ location = Path('/dev/shm').resolve()
57
+ mounts = [row.split() for row in Path('/proc/mounts').read_text().splitlines()]
58
+ enclosing = [row for row in mounts if Path(row[1]) == location or Path(row[1]) in location.parents]
59
+ if not enclosing or max(enclosing, key=lambda row: len(row[1]))[2] != 'tmpfs':
60
+ raise RuntimeError('Isolated Codex runs require memory-backed /dev/shm')
61
+ if auth.get('tokens', {}).get('refresh_token') != '':
62
+ raise RuntimeError('Refusing a rotating sandbox credential')
63
+ with tempfile.TemporaryDirectory(prefix='cdx-observation-', dir=location) as directory:
64
+ path = Path(directory) / 'auth.json'
65
+ with open(path, 'x', opener=lambda name, flags: os.open(name, flags, 0o600)) as handle:
66
+ json.dump(auth, handle)
67
+ (Path(directory) / 'sessions').mkdir(mode=0o700)
68
+ yield directory
69
+
70
+
50
71
  def clean_env(source: dict) -> dict:
51
72
  allowed = ("PATH", "LANG", "LC_ALL", "TERM", "TZ")
52
73
  env = {key: source[key] for key in allowed if key in source}
@@ -126,6 +147,9 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
126
147
  raise RuntimeError("Expected an isolated git worktree")
127
148
  env = clean_env(source)
128
149
  if provider == "codex":
150
+ auth_path = Path(auth_home) / "auth.json"
151
+ if json.loads(auth_path.read_text()).get('tokens', {}).get('refresh_token') != '':
152
+ raise RuntimeError('Refusing a rotating sandbox credential')
129
153
  env["CODEX_HOME"] = "/home/agent/.codex"
130
154
  if observation_home:
131
155
  # Fresh, volatile attempt storage; no host profile is exposed.