cdx-manager 0.25.1 → 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_2.md +15 -0
- package/changelogs/CHANGELOGS_0_25_3.md +20 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +28 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +90 -4
- package/package.json +1 -1
- package/pyproject.toml +7 -4
- package/src/cli.py +7 -5
- package/src/cli_args.py +22 -2
- package/src/codex_usage.py +5 -1
- package/src/commands/launch.py +13 -5
- package/src/commands/runs.py +120 -37
- package/src/commands/status.py +32 -4
- package/src/history_models.py +10 -1
- package/src/project_identity.py +71 -0
- package/src/provider_runtime.py +64 -9
- package/src/remote_run.py +230 -0
- package/src/remote_worker.py +233 -0
- package/src/run_command.py +16 -1
- package/src/run_observation.py +93 -0
- package/src/run_registry.py +23 -2
- package/src/run_usage.py +38 -4
- package/src/session_service.py +11 -0
- package/src/session_store.py +62 -0
- package/src/workspace_sandbox.py +28 -1
package/src/provider_runtime.py
CHANGED
|
@@ -1068,13 +1068,15 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
1068
1068
|
)
|
|
1069
1069
|
|
|
1070
1070
|
|
|
1071
|
-
def _headless_artifact_paths(session, run_id=None):
|
|
1071
|
+
def _headless_artifact_paths(session, run_id=None, attempt=1):
|
|
1072
1072
|
run_id = run_id or uuid.uuid4().hex
|
|
1073
1073
|
log_dir = _get_launch_transcript_dir(session)
|
|
1074
1074
|
os.makedirs(log_dir, exist_ok=True)
|
|
1075
|
-
|
|
1075
|
+
attempt_id = f"{run_id}:{attempt}"
|
|
1076
|
+
prefix = os.path.join(log_dir, f"cdx-run-{run_id}-attempt-{attempt}")
|
|
1076
1077
|
return {
|
|
1077
1078
|
"run_id": run_id,
|
|
1079
|
+
"attempt_id": attempt_id,
|
|
1078
1080
|
"transcript_path": os.path.abspath(f"{prefix}.log"),
|
|
1079
1081
|
"stdout_path": os.path.abspath(f"{prefix}.stdout.log"),
|
|
1080
1082
|
"stderr_path": os.path.abspath(f"{prefix}.stderr.log"),
|
|
@@ -1205,7 +1207,33 @@ def _read_permission_denials(stdout_path):
|
|
|
1205
1207
|
|
|
1206
1208
|
|
|
1207
1209
|
def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
|
|
1208
|
-
timeout_seconds=None, spawn=None, run_id=None, budget_check=None):
|
|
1210
|
+
timeout_seconds=None, spawn=None, run_id=None, budget_check=None, attempt=1):
|
|
1211
|
+
from .remote_run import remote_auth_lock, temporary_auth
|
|
1212
|
+
from .workspace_sandbox import private_auth_home
|
|
1213
|
+
|
|
1214
|
+
isolated_codex = session.get("provider") == PROVIDER_CODEX and (session.get("launch") or {}).get("isolate_workspace")
|
|
1215
|
+
if isolated_codex:
|
|
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)
|
|
1231
|
+
return _run_headless_provider_attempt(session, cwd, env_override, initial_prompt,
|
|
1232
|
+
timeout_seconds, spawn, run_id, budget_check, attempt=attempt)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial_prompt=None,
|
|
1236
|
+
timeout_seconds=None, spawn=None, run_id=None, budget_check=None, observation_home=None, attempt=1):
|
|
1209
1237
|
spawn = spawn or subprocess.Popen
|
|
1210
1238
|
spec = _build_headless_launch_spec(
|
|
1211
1239
|
session,
|
|
@@ -1248,14 +1276,23 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1248
1276
|
[spec["command"], *spec["args"]], Path(cwd),
|
|
1249
1277
|
network=launch.get("network_access", False),
|
|
1250
1278
|
writable=launch.get("permission") != "review",
|
|
1251
|
-
source=spec["options"]["env"], auth_home=_get_auth_home(session),
|
|
1252
|
-
provider=session.get("provider")
|
|
1279
|
+
source=spec["options"]["env"], auth_home=observation_home or _get_auth_home(session),
|
|
1280
|
+
provider=session.get("provider"),
|
|
1281
|
+
**({"observation_home": os.path.join(observation_home, "sessions")} if observation_home else {}))
|
|
1253
1282
|
except (OSError, RuntimeError) as error:
|
|
1254
1283
|
raise CdxError(str(error)) from error
|
|
1255
1284
|
spec.update(command=argv[0], args=argv[1:])
|
|
1256
1285
|
spec["options"]["env"] = child_env
|
|
1257
|
-
paths = _headless_artifact_paths(session, run_id=run_id)
|
|
1286
|
+
paths = _headless_artifact_paths(session, run_id=run_id, attempt=attempt)
|
|
1258
1287
|
start_time = datetime.now(timezone.utc)
|
|
1288
|
+
|
|
1289
|
+
def snapshot():
|
|
1290
|
+
if observation_home:
|
|
1291
|
+
from .run_observation import codex_attempt_evidence
|
|
1292
|
+
paths["usage_evidence"] = codex_attempt_evidence(
|
|
1293
|
+
observation_home, paths["stdout_path"], start_time.isoformat())
|
|
1294
|
+
paths["usage_evidence"].update(run_id=paths["run_id"], attempt_id=paths.get("attempt_id"))
|
|
1295
|
+
|
|
1259
1296
|
command = spec["command"]
|
|
1260
1297
|
if spawn is subprocess.Popen:
|
|
1261
1298
|
command = _resolve_command(command, spec.get("options", {}).get("env"))
|
|
@@ -1264,6 +1301,12 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1264
1301
|
timed_out = False
|
|
1265
1302
|
cancelled = False
|
|
1266
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'])
|
|
1267
1310
|
with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
|
|
1268
1311
|
open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
|
|
1269
1312
|
options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
|
|
@@ -1292,10 +1335,15 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1292
1335
|
# this supervisor and keep its run "running" forever.
|
|
1293
1336
|
previous_handlers = _route_termination_to_interrupt()
|
|
1294
1337
|
try:
|
|
1295
|
-
if budget_check is not None:
|
|
1338
|
+
if budget_check is not None or expires_at is not None:
|
|
1296
1339
|
deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
|
|
1297
1340
|
while True:
|
|
1298
|
-
|
|
1341
|
+
snapshot()
|
|
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):
|
|
1299
1347
|
budget_exceeded = True
|
|
1300
1348
|
_terminate_child_tree(child)
|
|
1301
1349
|
break
|
|
@@ -1304,7 +1352,8 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1304
1352
|
raise subprocess.TimeoutExpired(command, timeout_seconds)
|
|
1305
1353
|
try:
|
|
1306
1354
|
child.wait(timeout=min(0.25, remaining) if remaining is not None else 0.25)
|
|
1307
|
-
|
|
1355
|
+
snapshot()
|
|
1356
|
+
budget_exceeded = bool(budget_check and budget_check(paths))
|
|
1308
1357
|
break
|
|
1309
1358
|
except subprocess.TimeoutExpired:
|
|
1310
1359
|
continue
|
|
@@ -1325,8 +1374,14 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1325
1374
|
cancelled = True
|
|
1326
1375
|
_terminate_child_tree(child)
|
|
1327
1376
|
finally:
|
|
1377
|
+
snapshot()
|
|
1328
1378
|
_restore_signal_handlers(previous_handlers)
|
|
1329
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
|
|
1330
1385
|
if cancelled:
|
|
1331
1386
|
_combine_headless_transcript(paths)
|
|
1332
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
|
@@ -4,7 +4,7 @@ import sys
|
|
|
4
4
|
from .cli_args import ARG_CODE_INVALID_VALUE
|
|
5
5
|
from .errors import CdxArgumentError, CdxError
|
|
6
6
|
from .provider_runtime import headless_permission_disables_network
|
|
7
|
-
from .run_usage import empty_usage, estimate_cost, extract_run_observation
|
|
7
|
+
from .run_usage import cost_fields, empty_usage, estimate_cost, extract_run_observation
|
|
8
8
|
|
|
9
9
|
PROMPT_STDIN = "-"
|
|
10
10
|
|
|
@@ -169,6 +169,9 @@ 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 {}),
|
|
173
|
+
"project": parsed.get("project"),
|
|
174
|
+
"project_source": parsed.get("project_source", "unknown"),
|
|
172
175
|
"parent_run_id": parsed.get("continue_run"),
|
|
173
176
|
"conversation_id": conversation_id or run_conversation_id(session),
|
|
174
177
|
"detached": True,
|
|
@@ -219,6 +222,9 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
|
|
|
219
222
|
"ok": bool(ok),
|
|
220
223
|
"action": "run",
|
|
221
224
|
"launcher": "cdx",
|
|
225
|
+
**({"remote": parsed["remote"]} if parsed.get("remote") else {}),
|
|
226
|
+
"project": parsed.get("project"),
|
|
227
|
+
"project_source": parsed.get("project_source", "unknown"),
|
|
222
228
|
"parent_run_id": parsed.get("continue_run"),
|
|
223
229
|
"conversation_id": run_conversation_id(session, run_info),
|
|
224
230
|
"session": session.get("name") if session else None,
|
|
@@ -229,6 +235,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
|
|
|
229
235
|
"power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
|
|
230
236
|
"cwd": os.path.abspath(parsed.get("cwd") or os.getcwd()),
|
|
231
237
|
"run_id": run_info.get("run_id"),
|
|
238
|
+
"attempt_id": run_info.get("attempt_id"),
|
|
239
|
+
"attempts": run_info.get("attempts", []),
|
|
232
240
|
"exit_code": run_info.get("returncode"),
|
|
233
241
|
"duration_seconds": (run_info.get("duration_ms") / 1000.0) if run_info.get("duration_ms") is not None else None,
|
|
234
242
|
"transcript_path": run_info.get("transcript_path"),
|
|
@@ -236,7 +244,14 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
|
|
|
236
244
|
"stderr_path": run_info.get("stderr_path"),
|
|
237
245
|
"usage": usage,
|
|
238
246
|
"usage_model": usage_model,
|
|
247
|
+
"usage_models": run_info.get("usage_models", [usage_model] if usage_model else []),
|
|
248
|
+
**cost_fields(usage, usage_model, models=run_info.get("usage_models", (run_info.get("usage_evidence") or {}).get("models", [])), attribution=run_info.get("usage_attribution")),
|
|
239
249
|
"cost_usd": run_info.get("cost_usd", estimate_cost(usage, usage_model)),
|
|
250
|
+
"usage_evidence": run_info.get("usage_evidence"),
|
|
251
|
+
"usage_attribution": run_info.get("usage_attribution"),
|
|
252
|
+
"budget_status": run_info.get("budget_status", "exceeded" if run_info.get("budget_exceeded") else
|
|
253
|
+
"usage_not_measured" if parsed.get("budget") is not None and usage.get("total_tokens") is None else
|
|
254
|
+
"estimated" if parsed.get("budget") is not None else "not_requested"),
|
|
240
255
|
"budget_usd": parsed.get("budget"),
|
|
241
256
|
"warnings": warnings,
|
|
242
257
|
"error": None if ok else {
|