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 +99 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_25_3.md +20 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +28 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +1 -1
- package/pyproject.toml +7 -4
- package/src/cli.py +1 -1
- package/src/cli_args.py +18 -1
- package/src/codex_usage.py +5 -1
- package/src/commands/runs.py +34 -15
- package/src/provider_runtime.py +36 -11
- package/src/remote_run.py +230 -0
- package/src/remote_worker.py +233 -0
- package/src/run_command.py +2 -0
- package/src/run_registry.py +7 -1
- package/src/workspace_sandbox.py +24 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
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.
|
|
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
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.
|
|
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>=
|
|
29
|
+
dependencies = ["cryptography>=50.0.0"]
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
32
|
dev = [
|
|
33
|
-
|
|
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"],
|
package/src/codex_usage.py
CHANGED
|
@@ -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
|
package/src/commands/runs.py
CHANGED
|
@@ -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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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,
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
|
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)
|
package/src/run_command.py
CHANGED
|
@@ -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"),
|
package/src/run_registry.py
CHANGED
|
@@ -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)
|
package/src/workspace_sandbox.py
CHANGED
|
@@ -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.
|