cdx-manager 0.25.2 → 0.25.3

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.3-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,101 @@ 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; unknown prices and delayed provider reporting retain the usual budget
182
+ limitations. A run that outlives the access credential fails with
183
+ `remote_credential_expired`, requiring a new controller launch.
184
+
185
+ The empty-refresh-token behavior was measured with Codex CLI 0.166.0. Real-host
186
+ acceptance should verify that the installed matching CLI does not attempt a
187
+ refresh with the temporary home, that the controller token chain stays unchanged
188
+ for a fresh credential, and that cleanup works after disconnect and SIGTERM.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.25.2
1
+ 0.25.3
@@ -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`)
@@ -1,7 +1,35 @@
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 Codex runs
16
+
17
+ - Add `cdx run --remote SSH_TARGET` with controller-owned supervision, registry, history, output, costs, budgets and detached execution.
18
+ - 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.
19
+ - 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.
20
+ - Cover transport and credential isolation, thresholds, cleanup, sweep, unreachable workers, provider refusal and registry reports with a fake SSH worker.
21
+
22
+ ## Remote worker transport and isolation preflight
23
+
24
+ - 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.
25
+ - 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.
26
+ - 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.
27
+ - 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.
28
+ - 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.
29
+
30
+ ## Isolated Codex credentials
31
+
32
+ - 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.
33
+ - 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.
34
+ - 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.
35
+ - 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,17 @@
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
+ }
708
719
  }
709
720
  }
710
721
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.25.2",
3
+ "version": "0.25.3",
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.3"
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"),
@@ -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,230 @@
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 time
11
+ from contextlib import contextmanager
12
+ from datetime import datetime, timezone
13
+
14
+ from .codex_usage import _request_codex_app_server, codex_auth_lock
15
+ from .errors import CdxError
16
+
17
+
18
+ def token_expiry(token):
19
+ try:
20
+ part = token.split('.')[1]
21
+ expiry = float(json.loads(base64.urlsafe_b64decode(part + '=' * (-len(part) % 4)))['exp'])
22
+ if not math.isfinite(expiry) or expiry <= 0:
23
+ raise ValueError('Invalid expiration')
24
+ return expiry
25
+ except (ValueError, KeyError, IndexError, TypeError) as error:
26
+ raise CdxError('Cannot determine remote access token expiration; log in on the controller.') from error
27
+
28
+
29
+ def needs_refresh(auth, now):
30
+ try:
31
+ refreshed = datetime.fromisoformat(auth['last_refresh'].replace('Z', '+00:00')).timestamp()
32
+ except (ValueError, KeyError, TypeError):
33
+ return True
34
+ return now - refreshed > 7 * 86400 or token_expiry(auth['tokens']['access_token']) - now < 86400
35
+
36
+
37
+ def refresh_auth(home):
38
+ result = _request_codex_app_server(home, 'account/read', {'refreshToken': True}, 'refresh_failed', timeout=30)
39
+ if not result.get('ok'):
40
+ raise CdxError('Controller Codex credential refresh failed; log in on the controller.')
41
+
42
+
43
+ def temporary_auth(home, now=None):
44
+ now = time.time() if now is None else now
45
+ try:
46
+ auth = json.loads((pathlib.Path(home) / 'auth.json').read_text())
47
+ if needs_refresh(auth, now):
48
+ refresh_auth(home)
49
+ auth = json.loads((pathlib.Path(home) / 'auth.json').read_text())
50
+ if needs_refresh(auth, now):
51
+ raise CdxError('Controller refresh did not produce a fresh remote credential.')
52
+ tokens = {key: auth['tokens'][key] for key in ('access_token', 'id_token', 'account_id')}
53
+ tokens['refresh_token'] = ''
54
+ result = {'tokens': tokens, 'last_refresh': auth['last_refresh']}
55
+ rotating = auth['tokens'].get('refresh_token')
56
+ if result['tokens']['refresh_token'] != '' or (rotating and rotating in json.dumps(result)):
57
+ raise CdxError('Refusing to send a refresh token to a worker.')
58
+ return result
59
+ except (OSError, ValueError, KeyError, TypeError) as error:
60
+ raise CdxError('Cannot read controller Codex OAuth credentials; log in on the controller.') from error
61
+
62
+
63
+ def nonsecret_config(home):
64
+ # Copy only known scalar preferences. Provider URLs, hooks, env and arbitrary
65
+ # config entries can contain secrets or refer to controller-only programs.
66
+ path = pathlib.Path(home) / 'config.toml'
67
+ if not path.exists():
68
+ return ''
69
+ allowed = {'model', 'model_reasoning_effort', 'service_tier', 'personality',
70
+ 'model_context_window', 'model_auto_compact_token_limit'}
71
+ lines = []
72
+ for line in path.read_text().splitlines():
73
+ if line.lstrip().startswith('['):
74
+ break
75
+ key, sep, value = line.partition('=')
76
+ if sep and key.strip() in allowed and re.fullmatch(r'\s*("[a-zA-Z0-9_. /-]+"|[0-9]+)\s*', value):
77
+ lines.append(line)
78
+ return '\n'.join(lines) + '\n'
79
+
80
+
81
+ def worker_input():
82
+ # One JSON line frames the source, leaving the next line for the payload.
83
+ directory = pathlib.Path(__file__).parent
84
+ source = 'SANDBOX_SOURCE = ' + repr((directory / 'workspace_sandbox.py').read_text()) + '\n'
85
+ source += (directory / 'remote_worker.py').read_text()
86
+ return json.dumps(source).encode() + b'\n'
87
+
88
+
89
+ def ssh_command(target, mode, remote_prefix=None):
90
+ if not re.fullmatch(r'[a-zA-Z0-9_@.:[\]-]+', target) or target.startswith('-'):
91
+ raise CdxError('Invalid --remote SSH target; use a host alias or user@host.')
92
+ prefix = (remote_prefix or '').strip()
93
+ if prefix and not re.fullmatch(r'[a-zA-Z0-9_./:=+ -]+', prefix):
94
+ raise CdxError('Invalid --remote-prefix; use a plain command prefix such as wsl.exe -d Ubuntu --.')
95
+ if mode not in ('probe', 'run'):
96
+ raise CdxError('Invalid remote worker mode.')
97
+ # Double quotes work in both POSIX sh and cmd.exe. The bootstrap contains
98
+ # no embedded double quotes, shell expansions, or worker source in argv.
99
+ command = (prefix + ' ' if prefix else '') + 'python3 -u -c "import sys,json;exec(json.loads(sys.stdin.buffer.readline()))" ' + mode
100
+ if len(command) > 8000:
101
+ raise CdxError('Remote command prefix exceeds the Windows command-line limit.')
102
+ return ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'ConnectionAttempts=1',
103
+ '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=1', target, command]
104
+
105
+
106
+ def preflight(target, env=None, *, remote_prefix=None, isolate_workspace=False, cwd=None, network=False, writable=True):
107
+ payload = {'isolate_workspace': isolate_workspace, 'cwd': cwd, 'network': network, 'writable': writable}
108
+ try:
109
+ local = subprocess.check_output(['codex', '--version'], env=env, stderr=subprocess.DEVNULL, timeout=10).decode().strip()
110
+ result = subprocess.run(ssh_command(target, 'probe', remote_prefix), env=env,
111
+ input=worker_input() + json.dumps(payload).encode() + b'\n',
112
+ capture_output=True, timeout=30)
113
+ except (OSError, subprocess.TimeoutExpired) as error:
114
+ raise CdxError('Remote worker unreachable or Codex CLI unavailable.', code='remote_unavailable') from error
115
+ if result.returncode:
116
+ message = ('Remote workspace isolation unavailable; requires bubblewrap, pasta, nftables, working namespaces and a valid checkout.'
117
+ if isolate_workspace else 'Remote worker unreachable or missing Python 3, Codex or memory-backed /dev/shm.')
118
+ raise CdxError(message, code='remote_unavailable')
119
+ if local != result.stdout.decode().strip():
120
+ raise CdxError('Remote Codex CLI version mismatch; install the controller version on the worker.', code='remote_version_mismatch')
121
+ return local
122
+
123
+
124
+ @contextmanager
125
+ def remote_auth_lock(home):
126
+ try:
127
+ with codex_auth_lock(home, strict=True) as acquired:
128
+ if not acquired:
129
+ raise CdxError('Codex profile is busy on the controller or a worker.', code='session_busy')
130
+ yield
131
+ except OSError as error:
132
+ raise CdxError('Unable to hold the controller authentication lock.', code='auth_lock_failed') from error
133
+
134
+
135
+ def run_remote(session, target, version, cwd=None, env_override=None, initial_prompt=None,
136
+ timeout_seconds=None, run_id=None, attempt=1, budget_check=None, remote_prefix=None, **_):
137
+ from .provider_runtime import (
138
+ _build_headless_launch_spec,
139
+ _combine_headless_transcript,
140
+ _headless_artifact_paths,
141
+ _headless_run_info,
142
+ _restore_signal_handlers,
143
+ _route_termination_to_interrupt,
144
+ )
145
+
146
+ home = session['authHome']
147
+ with remote_auth_lock(home):
148
+ auth = temporary_auth(home)
149
+ if auth['tokens'].get('refresh_token') != '':
150
+ raise CdxError('Refusing to send a refresh token to a worker.')
151
+ spec = _build_headless_launch_spec(session, cwd, env_override, initial_prompt)
152
+ if (session.get('launch') or {}).get('network_access'):
153
+ index = spec['args'].index('--') if '--' in spec['args'] else len(spec['args'])
154
+ spec['args'][index:index] = ['-c', 'sandbox_workspace_write.network_access=true']
155
+ config = nonsecret_config(home)
156
+ launch_text = json.dumps(spec['args']) + config
157
+ if any(value and value in launch_text for value in auth['tokens'].values()):
158
+ raise CdxError('Refusing a credential embedded in the remote prompt or configuration.')
159
+ payload = {'auth': auth, 'config': config, 'args': spec['args'], 'cwd': cwd,
160
+ 'version': version, 'expires_at': token_expiry(auth['tokens']['access_token']),
161
+ 'isolate_workspace': bool((session.get('launch') or {}).get('isolate_workspace')),
162
+ 'network': bool((session.get('launch') or {}).get('network_access')),
163
+ 'writable': (session.get('launch') or {}).get('permission') != 'review'}
164
+ rotating = json.loads((pathlib.Path(home) / 'auth.json').read_text())['tokens'].get('refresh_token')
165
+ if rotating and rotating in json.dumps(payload):
166
+ raise CdxError('Refusing to send a refresh token to a worker.')
167
+ paths = _headless_artifact_paths(session, run_id, attempt)
168
+ paths['remote'] = target
169
+ start = datetime.now(timezone.utc)
170
+ child = None
171
+ cancelled = timed_out = budget_exceeded = credential_expired = False
172
+ handlers = _route_termination_to_interrupt()
173
+ 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)
191
+ except KeyboardInterrupt:
192
+ cancelled = True
193
+ except (OSError, ValueError) as error:
194
+ raise CdxError('Remote SSH transport failed.', code='remote_unavailable') from error
195
+ finally:
196
+ if child:
197
+ # EOF makes the worker stop its process group and remove the home.
198
+ try:
199
+ child.stdin.close()
200
+ except (OSError, BrokenPipeError):
201
+ pass
202
+ try:
203
+ child.wait(timeout=10)
204
+ except subprocess.TimeoutExpired:
205
+ child.terminate()
206
+ try:
207
+ child.wait(timeout=2)
208
+ except subprocess.TimeoutExpired:
209
+ child.kill()
210
+ child.wait()
211
+ _restore_signal_handlers(handlers)
212
+ _combine_headless_transcript(paths)
213
+ info = {**_headless_run_info(paths, spec, start, child.returncode), 'remote': target,
214
+ 'timed_out': timed_out, 'budget_exceeded': budget_exceeded}
215
+ if cancelled:
216
+ error = CdxError('Remote run was cancelled.', 130)
217
+ error.cancelled = True
218
+ error.run_info = {**info, 'returncode': 130}
219
+ raise error
220
+ if credential_expired or time.time() >= payload['expires_at'] or child.returncode == 123:
221
+ error = CdxError('Remote temporary credential expired; start a new run from the controller.', code='remote_credential_expired')
222
+ error.run_info = info
223
+ raise error
224
+ if child.returncode == 255 and not (timed_out or budget_exceeded):
225
+ error = CdxError('Remote SSH connection failed or was lost.', code='remote_unavailable')
226
+ error.run_info = info
227
+ raise error
228
+ if timed_out or budget_exceeded:
229
+ info['returncode'] = 125 if budget_exceeded else 124
230
+ return info
@@ -0,0 +1,233 @@
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
+
16
+ # Supplied by the controller before this module is executed.
17
+ SANDBOX_SOURCE = globals().get('SANDBOX_SOURCE', '')
18
+
19
+
20
+ def version():
21
+ return subprocess.check_output(["codex", "--version"], stderr=subprocess.DEVNULL, timeout=10).decode().strip()
22
+
23
+
24
+ def storage():
25
+ mounts = [row.split() for row in pathlib.Path('/proc/mounts').read_text().splitlines()]
26
+ location = pathlib.Path('/dev/shm').resolve()
27
+ enclosing = [row for row in mounts if pathlib.Path(row[1]) == location or pathlib.Path(row[1]) in location.parents]
28
+ if not enclosing or max(enclosing, key=lambda row: len(row[1]))[2] != 'tmpfs':
29
+ raise RuntimeError("Remote execution requires memory-backed /dev/shm")
30
+ root = pathlib.Path('/dev/shm') / f'cdx-remote-{os.getuid()}'
31
+ root.mkdir(mode=0o700, exist_ok=True)
32
+ info = root.lstat()
33
+ if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
34
+ raise RuntimeError("Unsafe remote credential directory")
35
+ return root
36
+
37
+
38
+ def allocate(root):
39
+ # Serialize sweep and allocation so another launcher cannot sweep a new home.
40
+ with open(root / '.sweep', 'a') as sweep:
41
+ fcntl.flock(sweep, fcntl.LOCK_EX)
42
+ for path in root.glob('run-*'):
43
+ if path.is_symlink() or not path.is_dir():
44
+ continue
45
+ try:
46
+ with open(path / '.live', 'a') as live:
47
+ fcntl.flock(live, fcntl.LOCK_EX | fcntl.LOCK_NB)
48
+ shutil.rmtree(path)
49
+ except (BlockingIOError, FileNotFoundError):
50
+ continue
51
+ home = pathlib.Path(tempfile.mkdtemp(prefix='run-', dir=root))
52
+ live = open(home / '.live', 'a')
53
+ fcntl.flock(live, fcntl.LOCK_EX)
54
+ return home, live
55
+
56
+
57
+ def isolation_probe_command(payload, home, env):
58
+ # Execute exactly the local confinement implementation, shipped over stdin.
59
+ namespace = {'__name__': 'cdx_workspace_sandbox'}
60
+ exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
61
+ return namespace['sandbox_command'](
62
+ ['codex', '--version'], pathlib.Path(payload['cwd']), network=payload['network'],
63
+ writable=payload['writable'], source=env, auth_home=str(home), provider='codex',
64
+ observation_home=str(home / 'sessions'))
65
+
66
+
67
+ def probe(root, payload):
68
+ if not payload.get('isolate_workspace'):
69
+ print(version())
70
+ return 0
71
+ # Exercise mounts, namespaces and the nft setup with a dummy home before
72
+ # the controller reads or sends a credential. Binary presence is insufficient.
73
+ with tempfile.TemporaryDirectory(prefix='probe-', dir=root) as directory:
74
+ home = pathlib.Path(directory)
75
+ (home / 'auth.json').write_text(json.dumps({'tokens': {'refresh_token': ''}}))
76
+ os.chmod(home / 'auth.json', 0o600)
77
+ (home / 'sessions').mkdir()
78
+ command, env = isolation_probe_command(payload, home, dict(os.environ))
79
+ result = subprocess.run(command, env=env, stdin=subprocess.DEVNULL,
80
+ capture_output=True, timeout=20)
81
+ if result.returncode:
82
+ raise RuntimeError('Remote workspace isolation unavailable')
83
+ print(result.stdout.decode().strip())
84
+ return 0
85
+
86
+
87
+ def main():
88
+ root = storage()
89
+ payload = json.loads(sys.stdin.buffer.readline())
90
+ if sys.argv[-1] == 'probe':
91
+ return probe(root, payload)
92
+ auth = payload['auth']
93
+ if auth['tokens'].get('refresh_token') != '':
94
+ raise RuntimeError('Refusing a rotating remote credential')
95
+ if version() != payload['version']:
96
+ raise RuntimeError('Remote Codex CLI version mismatch')
97
+ child = None
98
+ stopped = threading.Event()
99
+
100
+ def stop(*_):
101
+ stopped.set()
102
+ if child is not None and child.poll() is None:
103
+ try:
104
+ os.killpg(child.pid, signal.SIGTERM)
105
+ child.wait(timeout=3)
106
+ except subprocess.TimeoutExpired:
107
+ os.killpg(child.pid, signal.SIGKILL)
108
+ child.wait()
109
+ except ProcessLookupError:
110
+ pass
111
+
112
+ def disconnected():
113
+ while os.read(0, 1):
114
+ pass
115
+ stop()
116
+
117
+ signal.signal(signal.SIGTERM, stop)
118
+ signal.signal(signal.SIGHUP, stop)
119
+ signal.signal(signal.SIGINT, stop)
120
+ home, live = allocate(root)
121
+ try:
122
+ if stopped.is_set():
123
+ return 130
124
+ os.umask(0o077)
125
+ (home / 'auth.json').write_text(json.dumps(auth))
126
+ os.chmod(home / 'auth.json', 0o600)
127
+ (home / 'config.toml').write_text('cli_auth_credentials_store = "file"\n' + payload['config'])
128
+ env = {key: value for key, value in os.environ.items()
129
+ if key not in ('OPENAI_API_KEY', 'OPENAI_ACCESS_TOKEN', 'CODEX_ACCESS_TOKEN', 'CODEX_API_KEY', 'CODEX_HOME')}
130
+ env['CODEX_HOME'] = str(home)
131
+ command = ['codex', *payload['args']]
132
+ if payload.get('isolate_workspace'):
133
+ (home / 'sessions').mkdir(mode=0o700)
134
+ args = payload['args'][:]
135
+ while '-s' in args:
136
+ index = args.index('-s')
137
+ del args[index:index + 2]
138
+ if '--dangerously-bypass-approvals-and-sandbox' not in args:
139
+ index = args.index('--') if '--' in args else len(args)
140
+ args[index:index] = ['--dangerously-bypass-approvals-and-sandbox']
141
+ namespace = {'__name__': 'cdx_workspace_sandbox'}
142
+ exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
143
+ command, env = namespace['sandbox_command'](
144
+ ['codex', *args], pathlib.Path(payload['cwd']), network=payload['network'],
145
+ writable=payload['writable'], source=env, auth_home=str(home), provider='codex',
146
+ observation_home=str(home / 'sessions'))
147
+ child = subprocess.Popen(command, cwd=payload['cwd'], env=env,
148
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
149
+ start_new_session=True)
150
+ if stopped.is_set():
151
+ stop()
152
+ threading.Thread(target=disconnected, daemon=True).start()
153
+ secrets = [value for value in auth['tokens'].values() if isinstance(value, str) and value]
154
+
155
+ output_lock = threading.Lock()
156
+ observed = {}
157
+
158
+ def snapshot():
159
+ # Only the fresh per-run home supplies model evidence. No transcript
160
+ # or authentication content leaves the worker.
161
+ 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
186
+
187
+ def forward(source, target):
188
+ for line in iter(source.readline, b''):
189
+ for secret in secrets:
190
+ line = line.replace(secret.encode(), b'[REDACTED]')
191
+ with output_lock:
192
+ 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()
203
+
204
+ threads = [threading.Thread(target=forward, args=pair) for pair in
205
+ ((child.stdout, sys.stdout), (child.stderr, sys.stderr))]
206
+ for thread in threads:
207
+ thread.start()
208
+ while child.poll() is None:
209
+ try:
210
+ child.wait(timeout=0.25)
211
+ except subprocess.TimeoutExpired:
212
+ snapshot()
213
+ result = child.returncode
214
+ for thread in threads:
215
+ thread.join()
216
+ snapshot()
217
+ if time.time() >= payload['expires_at']:
218
+ print('Remote temporary credential expired; start a new run from the controller.', file=sys.stderr)
219
+ return 123
220
+ return 130 if stopped.is_set() else result
221
+ finally:
222
+ stop()
223
+ shutil.rmtree(home)
224
+ live.close()
225
+
226
+
227
+ if __name__ == '__main__':
228
+ try:
229
+ sys.exit(main())
230
+ except Exception:
231
+ # Never render exception values: input and provider errors may contain credentials.
232
+ print('Remote worker setup or execution failed; check Python, Codex, cwd, /dev/shm and isolation dependencies.', file=sys.stderr)
233
+ 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"),
@@ -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.