cdx-manager 0.25.8 → 0.26.0
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 +100 -6
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_25_9.md +22 -0
- package/changelogs/CHANGELOGS_0_26_0.md +31 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +27 -1
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +69 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/antigravity_status.py +161 -0
- package/src/claude_remote_token.py +45 -0
- package/src/cli.py +14 -1
- package/src/cli_args.py +9 -4
- package/src/cli_commands.py +3 -2
- package/src/cli_helpers.py +1 -0
- package/src/commands/auth.py +25 -0
- package/src/commands/conversation.py +34 -0
- package/src/commands/maintenance.py +4 -1
- package/src/commands/runs.py +16 -3
- package/src/config.py +15 -0
- package/src/conversation.py +181 -0
- package/src/interactive_usage.py +6 -12
- package/src/ollama_remote.py +175 -0
- package/src/provider_runtime.py +15 -0
- package/src/remote_run.py +69 -25
- package/src/remote_worker.py +85 -33
- package/src/run_command.py +9 -0
- package/src/run_registry.py +5 -0
- package/src/run_usage.py +63 -2
- package/src/session_service.py +8 -1
- package/src/session_status.py +17 -0
- package/src/status_view.py +1 -0
- package/src/update_all.py +9 -3
- package/src/workspace_sandbox.py +126 -6
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Worker-owned Ollama relay and a text-only HTTP client, shipped over SSH."""
|
|
2
|
+
|
|
3
|
+
import http.client
|
|
4
|
+
import http.server
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import signal
|
|
9
|
+
import socketserver
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
|
|
16
|
+
OLLAMA_HOST = '127.0.0.1'
|
|
17
|
+
OLLAMA_PORT = 11434
|
|
18
|
+
MAX_BODY = 8 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
CLIENT = r'''
|
|
21
|
+
import http.client
|
|
22
|
+
import json
|
|
23
|
+
import socket
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
class Connection(http.client.HTTPConnection):
|
|
27
|
+
def connect(self):
|
|
28
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
29
|
+
self.sock.settimeout(300)
|
|
30
|
+
self.sock.connect(sys.argv[1])
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
payload = json.load(sys.stdin)
|
|
34
|
+
def request(path, body):
|
|
35
|
+
connection = Connection('localhost', timeout=300)
|
|
36
|
+
try:
|
|
37
|
+
connection.request('POST', path, json.dumps(body), {'Content-Type': 'application/json'})
|
|
38
|
+
response = connection.getresponse()
|
|
39
|
+
data = json.loads(response.read(8 * 1024 * 1024))
|
|
40
|
+
if response.status != 200 or not isinstance(data, dict) or data.get('error'):
|
|
41
|
+
raise ValueError('Ollama request failed')
|
|
42
|
+
return data
|
|
43
|
+
finally:
|
|
44
|
+
connection.close()
|
|
45
|
+
model = payload['model']
|
|
46
|
+
info = request('/api/show', {'model': model})
|
|
47
|
+
if info.get('remote_host') or info.get('remote_model'):
|
|
48
|
+
raise ValueError('A local model is required')
|
|
49
|
+
if 'capabilities' in info and 'completion' not in info['capabilities']:
|
|
50
|
+
raise ValueError('Text completion is required')
|
|
51
|
+
if payload.get('probe'):
|
|
52
|
+
print('ollama-http')
|
|
53
|
+
else:
|
|
54
|
+
result = request('/api/chat', {'model': model, 'stream': False,
|
|
55
|
+
'messages': [{'role': 'user', 'content': payload['prompt']}]})
|
|
56
|
+
if result.get('done') is not True or not isinstance(result.get('message', {}).get('content'), str):
|
|
57
|
+
raise ValueError('Incomplete Ollama response')
|
|
58
|
+
print(json.dumps(result))
|
|
59
|
+
except Exception:
|
|
60
|
+
print('Ollama text generation failed: check the local model and worker loopback API.', file=sys.stderr)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@contextmanager
|
|
66
|
+
def relay(directory, model):
|
|
67
|
+
"""Expose only a fixed loopback destination and a fixed model over a private socket."""
|
|
68
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
69
|
+
def log_message(self, *_):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def do_POST(self):
|
|
73
|
+
try:
|
|
74
|
+
size = int(self.headers.get('Content-Length', '0'))
|
|
75
|
+
if not 0 < size <= MAX_BODY or self.headers.get('Transfer-Encoding'):
|
|
76
|
+
raise ValueError('Invalid body')
|
|
77
|
+
self.connection.settimeout(10)
|
|
78
|
+
data = json.loads(self.rfile.read(size))
|
|
79
|
+
if not isinstance(data, dict) or data.get('model') != model:
|
|
80
|
+
raise ValueError('Invalid model')
|
|
81
|
+
if self.path == '/api/show':
|
|
82
|
+
allowed = set(data) == {'model'}
|
|
83
|
+
elif self.path == '/api/chat':
|
|
84
|
+
messages = data.get('messages')
|
|
85
|
+
allowed = (set(data) == {'model', 'stream', 'messages'} and data.get('stream') is False
|
|
86
|
+
and isinstance(messages, list) and len(messages) == 1
|
|
87
|
+
and isinstance(messages[0], dict) and set(messages[0]) == {'role', 'content'}
|
|
88
|
+
and messages[0]['role'] == 'user' and isinstance(messages[0]['content'], str))
|
|
89
|
+
else:
|
|
90
|
+
allowed = False
|
|
91
|
+
if not allowed:
|
|
92
|
+
raise ValueError('Forbidden call')
|
|
93
|
+
except (ValueError, TypeError, OSError):
|
|
94
|
+
self.send_error(403, 'Relay call refused')
|
|
95
|
+
return
|
|
96
|
+
connection = http.client.HTTPConnection(OLLAMA_HOST, OLLAMA_PORT, timeout=300)
|
|
97
|
+
try:
|
|
98
|
+
# No proxies, redirects, forwarded headers or client-selected destination.
|
|
99
|
+
connection.request('POST', self.path, json.dumps(data), {'Content-Type': 'application/json'})
|
|
100
|
+
response = connection.getresponse()
|
|
101
|
+
body = response.read(MAX_BODY + 1)
|
|
102
|
+
if len(body) > MAX_BODY:
|
|
103
|
+
raise ValueError('Oversized response')
|
|
104
|
+
self.send_response(response.status)
|
|
105
|
+
self.send_header('Content-Type', 'application/json')
|
|
106
|
+
self.send_header('Content-Length', str(len(body)))
|
|
107
|
+
self.end_headers()
|
|
108
|
+
self.wfile.write(body)
|
|
109
|
+
except (OSError, ValueError, http.client.HTTPException):
|
|
110
|
+
self.send_error(502, 'Ollama unavailable')
|
|
111
|
+
finally:
|
|
112
|
+
connection.close()
|
|
113
|
+
|
|
114
|
+
class Server(socketserver.ThreadingUnixStreamServer):
|
|
115
|
+
daemon_threads = True
|
|
116
|
+
|
|
117
|
+
path = str(pathlib.Path(directory) / 'api.sock')
|
|
118
|
+
with Server(path, Handler) as server:
|
|
119
|
+
os.chmod(path, 0o600)
|
|
120
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
121
|
+
thread.start()
|
|
122
|
+
try:
|
|
123
|
+
yield path
|
|
124
|
+
finally:
|
|
125
|
+
server.shutdown()
|
|
126
|
+
thread.join()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run(payload, sandbox_source, probe=False):
|
|
130
|
+
"""Run a single HTTP completion; no shell tools or model files enter the sandbox."""
|
|
131
|
+
model = payload.get('model')
|
|
132
|
+
if not isinstance(model, str) or not model.strip():
|
|
133
|
+
raise RuntimeError('Remote Ollama requires an installed model')
|
|
134
|
+
with tempfile.TemporaryDirectory(prefix='cdx-ollama-') as directory, relay(directory, model) as path:
|
|
135
|
+
command = [sys.executable, '-I', '-c', CLIENT, path]
|
|
136
|
+
env = None
|
|
137
|
+
if payload.get('isolate_workspace'):
|
|
138
|
+
namespace = {'__name__': 'cdx_workspace_sandbox'}
|
|
139
|
+
exec(compile(sandbox_source, 'workspace_sandbox.py', 'exec'), namespace)
|
|
140
|
+
command = ['python3', '-I', '-c', CLIENT, '/run/cdx-ollama/api.sock']
|
|
141
|
+
command, env = namespace['sandbox_command'](
|
|
142
|
+
command, pathlib.Path(payload['cwd']), network=False, writable=False,
|
|
143
|
+
provider='ollama', ollama_relay=directory)
|
|
144
|
+
child = subprocess.Popen(command, cwd=payload['cwd'], env=env, stdin=subprocess.PIPE,
|
|
145
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
|
146
|
+
|
|
147
|
+
def stop(*_):
|
|
148
|
+
if child.poll() is None:
|
|
149
|
+
try:
|
|
150
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
151
|
+
except ProcessLookupError:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def disconnected():
|
|
155
|
+
while os.read(0, 1):
|
|
156
|
+
pass
|
|
157
|
+
stop()
|
|
158
|
+
|
|
159
|
+
previous = {sig: signal.signal(sig, stop) for sig in (signal.SIGTERM, signal.SIGHUP, signal.SIGINT)}
|
|
160
|
+
if not probe:
|
|
161
|
+
threading.Thread(target=disconnected, daemon=True).start()
|
|
162
|
+
try:
|
|
163
|
+
output, error = child.communicate(json.dumps({'model': model, 'prompt': payload.get('prompt'), 'probe': probe}).encode(),
|
|
164
|
+
timeout=30 if probe else None)
|
|
165
|
+
if probe:
|
|
166
|
+
sys.stdout.write(output.decode())
|
|
167
|
+
else:
|
|
168
|
+
print(json.dumps({'stream': 'stdout', 'data': output.decode()}), flush=True)
|
|
169
|
+
sys.stderr.write(error.decode())
|
|
170
|
+
return child.returncode
|
|
171
|
+
finally:
|
|
172
|
+
stop()
|
|
173
|
+
child.wait()
|
|
174
|
+
for sig, handler in previous.items():
|
|
175
|
+
signal.signal(sig, handler)
|
package/src/provider_runtime.py
CHANGED
|
@@ -753,6 +753,9 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
753
753
|
"label": "claude",
|
|
754
754
|
}, capture_transcript=capture_transcript, env=env)
|
|
755
755
|
if session["provider"] == PROVIDER_ANTIGRAVITY:
|
|
756
|
+
from .antigravity_status import install
|
|
757
|
+
|
|
758
|
+
install(_get_auth_home(session))
|
|
756
759
|
args = _launch_config_args(session)
|
|
757
760
|
if initial_prompt:
|
|
758
761
|
args += ["--prompt-interactive", initial_prompt]
|
|
@@ -1059,6 +1062,18 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
1059
1062
|
"sensitive_args": [initial_prompt] if initial_prompt else [],
|
|
1060
1063
|
}
|
|
1061
1064
|
|
|
1065
|
+
if session["provider"] == PROVIDER_ANTIGRAVITY:
|
|
1066
|
+
args = ["-p", initial_prompt or "", "--output-format", "json"]
|
|
1067
|
+
if model:
|
|
1068
|
+
args += ["--model", model]
|
|
1069
|
+
args += _launch_config_args(session)
|
|
1070
|
+
args += _extra_args(session)
|
|
1071
|
+
return {
|
|
1072
|
+
"command": "agy", "args": args,
|
|
1073
|
+
"options": {"cwd": cwd, "env": {**env, **_antigravity_env_overrides(_get_auth_home(session))}},
|
|
1074
|
+
"label": "antigravity", "sensitive_args": [initial_prompt] if initial_prompt else [],
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1062
1077
|
return _build_launch_spec(
|
|
1063
1078
|
session,
|
|
1064
1079
|
cwd=cwd,
|
package/src/remote_run.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Controller-
|
|
1
|
+
"""Controller-owned remote provider credentials and SSH transport."""
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
4
|
import json
|
|
@@ -88,6 +88,7 @@ def worker_input():
|
|
|
88
88
|
for name in ('errors', 'run_usage', 'run_observation')}
|
|
89
89
|
source += 'OBSERVATION_MODULES = ' + repr(modules) + '\n'
|
|
90
90
|
source += f'OBSERVATION_LIMITS = {MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_SCAN_SECONDS!r}\n'
|
|
91
|
+
source += 'OLLAMA_SOURCE = ' + repr((directory / 'ollama_remote.py').read_text()) + '\n'
|
|
91
92
|
source += (directory / 'remote_worker.py').read_text()
|
|
92
93
|
return json.dumps(source).encode() + b'\n'
|
|
93
94
|
|
|
@@ -109,21 +110,32 @@ def ssh_command(target, mode, remote_prefix=None):
|
|
|
109
110
|
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=1', target, command]
|
|
110
111
|
|
|
111
112
|
|
|
112
|
-
def preflight(target, env=None, *, remote_prefix=None, isolate_workspace=False, cwd=None, network=False, writable=True):
|
|
113
|
+
def preflight(target, env=None, *, remote_prefix=None, isolate_workspace=False, cwd=None, network=False, writable=True, provider="codex", model=None):
|
|
113
114
|
payload = {'isolate_workspace': isolate_workspace, 'cwd': cwd, 'network': network, 'writable': writable}
|
|
115
|
+
if provider in ('ollama', 'claude', 'antigravity'):
|
|
116
|
+
payload.update(provider=provider, model=model)
|
|
117
|
+
if provider in ('claude', 'antigravity'):
|
|
118
|
+
payload['isolate_workspace'] = True
|
|
114
119
|
try:
|
|
115
|
-
local =
|
|
120
|
+
local = ('ollama-http' if provider == 'ollama' else
|
|
121
|
+
subprocess.check_output(['agy' if provider == 'antigravity' else provider, '--version'], env=env, stderr=subprocess.DEVNULL, timeout=10).decode().strip())
|
|
116
122
|
result = subprocess.run(ssh_command(target, 'probe', remote_prefix), env=env,
|
|
117
123
|
input=worker_input() + json.dumps(payload).encode() + b'\n',
|
|
118
124
|
capture_output=True, timeout=30)
|
|
119
125
|
except (OSError, subprocess.TimeoutExpired) as error:
|
|
120
|
-
raise CdxError('Remote worker unreachable or
|
|
126
|
+
raise CdxError('Remote worker unreachable or provider unavailable.', code='remote_unavailable') from error
|
|
121
127
|
if result.returncode:
|
|
128
|
+
if provider == 'antigravity' and result.returncode == 120:
|
|
129
|
+
raise CdxError('Antigravity worker is not signed in or its session file is unsafe; sign in once on the worker.',
|
|
130
|
+
code='antigravity_not_signed_in')
|
|
131
|
+
if provider == 'ollama':
|
|
132
|
+
raise CdxError('Remote Ollama unavailable: check the installed local model, loopback API and isolation dependencies.',
|
|
133
|
+
code='remote_unavailable')
|
|
122
134
|
message = ('Remote workspace isolation unavailable; requires bubblewrap, pasta, nftables, working namespaces and a valid checkout.'
|
|
123
|
-
if isolate_workspace else 'Remote worker unreachable or missing Python 3,
|
|
135
|
+
if payload['isolate_workspace'] else 'Remote worker unreachable or missing Python 3, provider CLI or memory-backed /dev/shm.')
|
|
124
136
|
raise CdxError(message, code='remote_unavailable')
|
|
125
137
|
if local != result.stdout.decode().strip():
|
|
126
|
-
raise CdxError('Remote
|
|
138
|
+
raise CdxError('Remote provider CLI version mismatch; install the controller version on the worker.', code='remote_version_mismatch')
|
|
127
139
|
return local
|
|
128
140
|
|
|
129
141
|
|
|
@@ -149,27 +161,53 @@ def run_remote(session, target, version, cwd=None, env_override=None, initial_pr
|
|
|
149
161
|
_route_termination_to_interrupt,
|
|
150
162
|
)
|
|
151
163
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
if session['provider'] == 'ollama':
|
|
165
|
+
launch = session.get('launch') or {}
|
|
166
|
+
spec = {'command': 'ollama-http', 'args': [], 'env': {}}
|
|
167
|
+
payload = {'provider': 'ollama', 'model': launch.get('model'), 'prompt': initial_prompt,
|
|
168
|
+
'cwd': cwd, 'isolate_workspace': bool(launch.get('isolate_workspace'))}
|
|
169
|
+
elif session['provider'] == 'antigravity':
|
|
170
|
+
spec = _build_headless_launch_spec(session, cwd, env_override, initial_prompt)
|
|
171
|
+
payload = {'provider': 'antigravity', 'args': spec['args'], 'cwd': cwd,
|
|
172
|
+
'version': version, 'isolate_workspace': True, 'network': False,
|
|
173
|
+
'writable': (session.get('launch') or {}).get('permission') != 'review'}
|
|
174
|
+
elif session['provider'] == 'claude':
|
|
175
|
+
from .claude_remote_token import read_token
|
|
176
|
+
token = read_token(session)
|
|
157
177
|
spec = _build_headless_launch_spec(session, cwd, env_override, initial_prompt)
|
|
158
|
-
if
|
|
159
|
-
index = spec['args'].index('--') if '--' in spec['args'] else len(spec['args'])
|
|
160
|
-
spec['args'][index:index] = ['-c', 'sandbox_workspace_write.network_access=true']
|
|
161
|
-
config = nonsecret_config(home)
|
|
162
|
-
launch_text = json.dumps(spec['args']) + config
|
|
163
|
-
if any(value and value in launch_text for value in auth['tokens'].values()):
|
|
178
|
+
if token in json.dumps(spec['args']):
|
|
164
179
|
raise CdxError('Refusing a credential embedded in the remote prompt or configuration.')
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
if '--bare' in spec['args']:
|
|
181
|
+
raise CdxError('Remote Claude OAuth runs do not support --bare.')
|
|
182
|
+
index = spec['args'].index('--') if '--' in spec['args'] else len(spec['args'])
|
|
183
|
+
# Bubblewrap owns tool permissions, matching isolated local headless runs.
|
|
184
|
+
spec['args'][index:index] = ['--permission-mode', 'bypassPermissions']
|
|
185
|
+
payload = {'provider': 'claude', 'token': token, 'args': spec['args'], 'cwd': cwd,
|
|
186
|
+
'version': version, 'isolate_workspace': True,
|
|
168
187
|
'network': bool((session.get('launch') or {}).get('network_access')),
|
|
169
188
|
'writable': (session.get('launch') or {}).get('permission') != 'review'}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
else:
|
|
190
|
+
home = session['authHome']
|
|
191
|
+
with remote_auth_lock(home):
|
|
192
|
+
auth = temporary_auth(home)
|
|
193
|
+
if auth['tokens'].get('refresh_token') != '':
|
|
194
|
+
raise CdxError('Refusing to send a refresh token to a worker.')
|
|
195
|
+
spec = _build_headless_launch_spec(session, cwd, env_override, initial_prompt)
|
|
196
|
+
if (session.get('launch') or {}).get('network_access'):
|
|
197
|
+
index = spec['args'].index('--') if '--' in spec['args'] else len(spec['args'])
|
|
198
|
+
spec['args'][index:index] = ['-c', 'sandbox_workspace_write.network_access=true']
|
|
199
|
+
config = nonsecret_config(home)
|
|
200
|
+
launch_text = json.dumps(spec['args']) + config
|
|
201
|
+
if any(value and value in launch_text for value in auth['tokens'].values()):
|
|
202
|
+
raise CdxError('Refusing a credential embedded in the remote prompt or configuration.')
|
|
203
|
+
payload = {'auth': auth, 'config': config, 'args': spec['args'], 'cwd': cwd,
|
|
204
|
+
'version': version, 'expires_at': token_expiry(auth['tokens']['access_token']),
|
|
205
|
+
'isolate_workspace': bool((session.get('launch') or {}).get('isolate_workspace')),
|
|
206
|
+
'network': bool((session.get('launch') or {}).get('network_access')),
|
|
207
|
+
'writable': (session.get('launch') or {}).get('permission') != 'review'}
|
|
208
|
+
rotating = json.loads((pathlib.Path(home) / 'auth.json').read_text())['tokens'].get('refresh_token')
|
|
209
|
+
if rotating and rotating in json.dumps(payload):
|
|
210
|
+
raise CdxError('Refusing to send a refresh token to a worker.')
|
|
173
211
|
paths = _headless_artifact_paths(session, run_id, attempt)
|
|
174
212
|
paths['remote'] = target
|
|
175
213
|
start = datetime.now(timezone.utc)
|
|
@@ -208,7 +246,7 @@ def run_remote(session, target, version, cwd=None, env_override=None, initial_pr
|
|
|
208
246
|
child.stdin.flush()
|
|
209
247
|
deadline = time.monotonic() + timeout_seconds if timeout_seconds else None
|
|
210
248
|
while child.poll() is None:
|
|
211
|
-
if time.time() >= payload
|
|
249
|
+
if time.time() >= payload.get('expires_at', float('inf')):
|
|
212
250
|
credential_expired = True
|
|
213
251
|
break
|
|
214
252
|
with observation_lock:
|
|
@@ -259,7 +297,7 @@ def run_remote(session, target, version, cwd=None, env_override=None, initial_pr
|
|
|
259
297
|
error.cancelled = True
|
|
260
298
|
error.run_info = {**info, 'returncode': 130}
|
|
261
299
|
raise error
|
|
262
|
-
if credential_expired or time.time() >= payload
|
|
300
|
+
if credential_expired or time.time() >= payload.get('expires_at', float('inf')) or child.returncode == 123:
|
|
263
301
|
error = CdxError('Remote temporary credential expired; start a new run from the controller.', code='remote_credential_expired')
|
|
264
302
|
error.run_info = info
|
|
265
303
|
raise error
|
|
@@ -269,4 +307,10 @@ def run_remote(session, target, version, cwd=None, env_override=None, initial_pr
|
|
|
269
307
|
raise error
|
|
270
308
|
if timed_out or budget_exceeded:
|
|
271
309
|
info['returncode'] = 125 if budget_exceeded else 124
|
|
310
|
+
if session['provider'] == 'antigravity':
|
|
311
|
+
from .run_usage import antigravity_outcome
|
|
312
|
+
info = antigravity_outcome(info)
|
|
313
|
+
if child.returncode in (120, 121):
|
|
314
|
+
info.update(returncode=1, provider_error='antigravity_not_signed_in' if child.returncode == 120 else 'antigravity_session_busy',
|
|
315
|
+
provider_message='Antigravity worker is not signed in.' if child.returncode == 120 else 'Antigravity worker session is busy.')
|
|
272
316
|
return info
|
package/src/remote_worker.py
CHANGED
|
@@ -13,10 +13,12 @@ import tempfile
|
|
|
13
13
|
import threading
|
|
14
14
|
import time
|
|
15
15
|
import types
|
|
16
|
+
from contextlib import ExitStack
|
|
16
17
|
from datetime import datetime, timezone
|
|
17
18
|
|
|
18
19
|
# Supplied by the controller before this module is executed.
|
|
19
20
|
SANDBOX_SOURCE = globals().get('SANDBOX_SOURCE', '')
|
|
21
|
+
OLLAMA_SOURCE = globals().get('OLLAMA_SOURCE', '')
|
|
20
22
|
OBSERVATION_MODULES = globals().get('OBSERVATION_MODULES', {})
|
|
21
23
|
OBSERVATION_LIMITS = globals().get('OBSERVATION_LIMITS', (128 * 1024 * 1024, 1))
|
|
22
24
|
|
|
@@ -34,8 +36,8 @@ def observation_runtime():
|
|
|
34
36
|
return sys.modules[package.__name__ + '.run_usage'], sys.modules[package.__name__ + '.run_observation']
|
|
35
37
|
|
|
36
38
|
|
|
37
|
-
def version():
|
|
38
|
-
return subprocess.check_output(["
|
|
39
|
+
def version(provider="codex"):
|
|
40
|
+
return subprocess.check_output(["agy" if provider == "antigravity" else provider, "--version"], stderr=subprocess.DEVNULL, timeout=10).decode().strip()
|
|
39
41
|
|
|
40
42
|
|
|
41
43
|
def storage():
|
|
@@ -75,16 +77,26 @@ def isolation_probe_command(payload, home, env):
|
|
|
75
77
|
# Execute exactly the local confinement implementation, shipped over stdin.
|
|
76
78
|
namespace = {'__name__': 'cdx_workspace_sandbox'}
|
|
77
79
|
exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
|
|
80
|
+
provider = payload.get('provider', 'codex')
|
|
78
81
|
return namespace['sandbox_command'](
|
|
79
|
-
['
|
|
80
|
-
writable=payload['writable'], source=env, auth_home=str(home), provider=
|
|
81
|
-
observation_home=str(home / 'sessions')
|
|
82
|
+
['agy' if provider == 'antigravity' else provider, '--version'], pathlib.Path(payload['cwd']), network=payload['network'],
|
|
83
|
+
writable=payload['writable'], source=env, auth_home=str(home), provider=provider,
|
|
84
|
+
observation_home=str(home) if provider in ('claude', 'antigravity') else str(home / 'sessions'),
|
|
85
|
+
claude_token_stdin=provider == 'claude')
|
|
82
86
|
|
|
83
87
|
|
|
84
88
|
def probe(root, payload):
|
|
85
89
|
if not payload.get('isolate_workspace'):
|
|
86
90
|
print(version())
|
|
87
91
|
return 0
|
|
92
|
+
if payload.get('provider') == 'antigravity':
|
|
93
|
+
namespace = {'__name__': 'cdx_workspace_sandbox'}
|
|
94
|
+
exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
|
|
95
|
+
try:
|
|
96
|
+
namespace['antigravity_token']()
|
|
97
|
+
except RuntimeError:
|
|
98
|
+
print('Antigravity worker is not signed in or its session file is unsafe.', file=sys.stderr)
|
|
99
|
+
return 120
|
|
88
100
|
# Exercise mounts, namespaces and the nft setup with a dummy home before
|
|
89
101
|
# the controller reads or sends a credential. Binary presence is insufficient.
|
|
90
102
|
with tempfile.TemporaryDirectory(prefix='probe-', dir=root) as directory:
|
|
@@ -93,7 +105,7 @@ def probe(root, payload):
|
|
|
93
105
|
os.chmod(home / 'auth.json', 0o600)
|
|
94
106
|
(home / 'sessions').mkdir()
|
|
95
107
|
command, env = isolation_probe_command(payload, home, dict(os.environ))
|
|
96
|
-
result = subprocess.run(command, env=env,
|
|
108
|
+
result = subprocess.run(command, env=env, input=b'"probe-only"\n' if payload.get('provider') == 'claude' else b'',
|
|
97
109
|
capture_output=True, timeout=20)
|
|
98
110
|
if result.returncode:
|
|
99
111
|
raise RuntimeError('Remote workspace isolation unavailable')
|
|
@@ -102,14 +114,21 @@ def probe(root, payload):
|
|
|
102
114
|
|
|
103
115
|
|
|
104
116
|
def main():
|
|
105
|
-
root = storage()
|
|
106
117
|
payload = json.loads(sys.stdin.buffer.readline())
|
|
118
|
+
if payload.get('provider') == 'ollama':
|
|
119
|
+
namespace = {'__name__': 'cdx_ollama_remote'}
|
|
120
|
+
exec(compile(OLLAMA_SOURCE, 'ollama_remote.py', 'exec'), namespace)
|
|
121
|
+
return namespace['run'](payload, SANDBOX_SOURCE, probe=sys.argv[-1] == 'probe')
|
|
122
|
+
root = storage()
|
|
107
123
|
if sys.argv[-1] == 'probe':
|
|
108
124
|
return probe(root, payload)
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
provider = payload.get('provider', 'codex')
|
|
126
|
+
agy = provider == 'antigravity'
|
|
127
|
+
claude = provider == 'claude'
|
|
128
|
+
auth = payload.get('auth')
|
|
129
|
+
if not (claude or agy) and auth['tokens'].get('refresh_token') != '':
|
|
111
130
|
raise RuntimeError('Refusing a rotating remote credential')
|
|
112
|
-
if version() != payload['version']:
|
|
131
|
+
if version(provider) != payload['version']:
|
|
113
132
|
raise RuntimeError('Remote Codex CLI version mismatch')
|
|
114
133
|
child = None
|
|
115
134
|
stopped = threading.Event()
|
|
@@ -135,40 +154,70 @@ def main():
|
|
|
135
154
|
signal.signal(signal.SIGHUP, stop)
|
|
136
155
|
signal.signal(signal.SIGINT, stop)
|
|
137
156
|
home, live = allocate(root)
|
|
157
|
+
auth_context = ExitStack()
|
|
138
158
|
try:
|
|
139
159
|
if stopped.is_set():
|
|
140
160
|
return 130
|
|
141
161
|
os.umask(0o077)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
if agy:
|
|
163
|
+
namespace = {'__name__': 'cdx_workspace_sandbox'}
|
|
164
|
+
exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
|
|
165
|
+
try:
|
|
166
|
+
attempt_home = auth_context.enter_context(namespace['antigravity_attempt'](home))
|
|
167
|
+
except RuntimeError as error:
|
|
168
|
+
print(str(error), file=sys.stderr)
|
|
169
|
+
return 121 if 'busy' in str(error) else 120
|
|
170
|
+
command, env = namespace['sandbox_command'](
|
|
171
|
+
['agy', *payload['args']], pathlib.Path(payload['cwd']), network=False,
|
|
172
|
+
writable=payload['writable'], source=dict(os.environ), provider='antigravity',
|
|
173
|
+
observation_home=attempt_home)
|
|
174
|
+
elif claude:
|
|
175
|
+
claude_home = home / 'claude-home'
|
|
176
|
+
claude_home.mkdir(mode=0o700)
|
|
177
|
+
if not payload.get('isolate_workspace') or '--bare' in payload['args']:
|
|
178
|
+
raise RuntimeError('Remote Claude requires isolated OAuth execution')
|
|
158
179
|
namespace = {'__name__': 'cdx_workspace_sandbox'}
|
|
159
180
|
exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
|
|
160
181
|
command, env = namespace['sandbox_command'](
|
|
161
|
-
['
|
|
162
|
-
writable=payload['writable'], source=
|
|
163
|
-
observation_home=str(
|
|
182
|
+
['claude', *payload['args']], pathlib.Path(payload['cwd']), network=payload['network'],
|
|
183
|
+
writable=payload['writable'], source=dict(os.environ), provider='claude',
|
|
184
|
+
observation_home=str(claude_home), claude_token_stdin=True)
|
|
185
|
+
else:
|
|
186
|
+
(home / 'auth.json').write_text(json.dumps(auth))
|
|
187
|
+
os.chmod(home / 'auth.json', 0o600)
|
|
188
|
+
(home / 'config.toml').write_text('cli_auth_credentials_store = "file"\n' + payload['config'])
|
|
189
|
+
env = {key: value for key, value in os.environ.items()
|
|
190
|
+
if key not in ('OPENAI_API_KEY', 'OPENAI_ACCESS_TOKEN', 'CODEX_ACCESS_TOKEN', 'CODEX_API_KEY', 'CODEX_HOME')}
|
|
191
|
+
env['CODEX_HOME'] = str(home)
|
|
192
|
+
command = ['codex', *payload['args']]
|
|
193
|
+
if payload.get('isolate_workspace'):
|
|
194
|
+
(home / 'sessions').mkdir(mode=0o700)
|
|
195
|
+
args = payload['args'][:]
|
|
196
|
+
while '-s' in args:
|
|
197
|
+
index = args.index('-s')
|
|
198
|
+
del args[index:index + 2]
|
|
199
|
+
if '--dangerously-bypass-approvals-and-sandbox' not in args:
|
|
200
|
+
index = args.index('--') if '--' in args else len(args)
|
|
201
|
+
args[index:index] = ['--dangerously-bypass-approvals-and-sandbox']
|
|
202
|
+
namespace = {'__name__': 'cdx_workspace_sandbox'}
|
|
203
|
+
exec(compile(SANDBOX_SOURCE, 'workspace_sandbox.py', 'exec'), namespace)
|
|
204
|
+
command, env = namespace['sandbox_command'](
|
|
205
|
+
['codex', *args], pathlib.Path(payload['cwd']), network=payload['network'],
|
|
206
|
+
writable=payload['writable'], source=env, auth_home=str(home), provider='codex',
|
|
207
|
+
observation_home=str(home / 'sessions'))
|
|
164
208
|
started_at = datetime.now(timezone.utc).isoformat()
|
|
165
209
|
child = subprocess.Popen(command, cwd=payload['cwd'], env=env,
|
|
166
|
-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
210
|
+
stdin=subprocess.PIPE if claude else subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
167
211
|
start_new_session=True)
|
|
212
|
+
if claude:
|
|
213
|
+
child.stdin.write(json.dumps(payload['token']).encode() + b'\n')
|
|
214
|
+
child.stdin.close()
|
|
168
215
|
if stopped.is_set():
|
|
169
216
|
stop()
|
|
170
217
|
threading.Thread(target=disconnected, daemon=True).start()
|
|
171
|
-
secrets = [
|
|
218
|
+
secrets = (namespace['antigravity_secrets']() if agy else
|
|
219
|
+
[payload['token']] if claude else
|
|
220
|
+
[value for value in auth['tokens'].values() if isinstance(value, str) and value])
|
|
172
221
|
|
|
173
222
|
output_lock = threading.Lock()
|
|
174
223
|
usage_reader, observation_reader = observation_runtime()
|
|
@@ -176,6 +225,8 @@ def main():
|
|
|
176
225
|
stdout_path.touch(mode=0o600)
|
|
177
226
|
|
|
178
227
|
def snapshot():
|
|
228
|
+
if claude or agy:
|
|
229
|
+
return
|
|
179
230
|
# Read the same exact-thread evidence as the local supervisor before
|
|
180
231
|
# volatile storage is removed. Only normalized metadata is exported.
|
|
181
232
|
with output_lock:
|
|
@@ -229,7 +280,7 @@ def main():
|
|
|
229
280
|
for thread in threads:
|
|
230
281
|
thread.join()
|
|
231
282
|
snapshot()
|
|
232
|
-
if time.time() >= payload
|
|
283
|
+
if time.time() >= payload.get('expires_at', float('inf')):
|
|
233
284
|
print('Remote temporary credential expired; start a new run from the controller.', file=sys.stderr)
|
|
234
285
|
return 123
|
|
235
286
|
return 130 if stopped.is_set() else result
|
|
@@ -237,6 +288,7 @@ def main():
|
|
|
237
288
|
stop()
|
|
238
289
|
shutil.rmtree(home)
|
|
239
290
|
live.close()
|
|
291
|
+
auth_context.close()
|
|
240
292
|
|
|
241
293
|
|
|
242
294
|
if __name__ == '__main__':
|
|
@@ -244,5 +296,5 @@ if __name__ == '__main__':
|
|
|
244
296
|
sys.exit(main())
|
|
245
297
|
except Exception:
|
|
246
298
|
# Never render exception values: input and provider errors may contain credentials.
|
|
247
|
-
print('Remote worker setup or execution failed; check Python,
|
|
299
|
+
print('Remote worker setup or execution failed; check Python, provider CLI, cwd, /dev/shm and isolation dependencies.', file=sys.stderr)
|
|
248
300
|
sys.exit(126)
|
package/src/run_command.py
CHANGED
|
@@ -4,6 +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_registry import final_message
|
|
7
8
|
from .run_usage import cost_fields, empty_usage, estimate_cost, extract_run_observation
|
|
8
9
|
|
|
9
10
|
PROMPT_STDIN = "-"
|
|
@@ -169,6 +170,9 @@ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
|
|
|
169
170
|
"ok": True,
|
|
170
171
|
"action": "run",
|
|
171
172
|
"launcher": "cdx",
|
|
173
|
+
**({"execution_mode": "text_generation",
|
|
174
|
+
"execution_note": "Ollama generates text only; it is not an autonomous coding agent and does not edit the repository."}
|
|
175
|
+
if parsed.get("remote") and session and session.get("provider") == "ollama" else {}),
|
|
172
176
|
**({"remote": parsed["remote"]} if parsed.get("remote") else {}),
|
|
173
177
|
"project": parsed.get("project"),
|
|
174
178
|
"project_source": parsed.get("project_source", "unknown"),
|
|
@@ -222,6 +226,9 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
|
|
|
222
226
|
"ok": bool(ok),
|
|
223
227
|
"action": "run",
|
|
224
228
|
"launcher": "cdx",
|
|
229
|
+
**({"execution_mode": "text_generation",
|
|
230
|
+
"execution_note": "Ollama generates text only; it is not an autonomous coding agent and does not edit the repository."}
|
|
231
|
+
if parsed.get("remote") and session and session.get("provider") == "ollama" else {}),
|
|
225
232
|
**({"remote": parsed["remote"]} if parsed.get("remote") else {}),
|
|
226
233
|
"project": parsed.get("project"),
|
|
227
234
|
"project_source": parsed.get("project_source", "unknown"),
|
|
@@ -242,6 +249,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
|
|
|
242
249
|
"transcript_path": run_info.get("transcript_path"),
|
|
243
250
|
"stdout_path": run_info.get("stdout_path"),
|
|
244
251
|
"stderr_path": run_info.get("stderr_path"),
|
|
252
|
+
**({"result": final_message({"artifacts": run_info})}
|
|
253
|
+
if parsed.get("remote") and session and session.get("provider") == "ollama" else {}),
|
|
245
254
|
"usage": usage,
|
|
246
255
|
"usage_model": usage_model,
|
|
247
256
|
"usage_models": run_info.get("usage_models", [usage_model] if usage_model else []),
|
package/src/run_registry.py
CHANGED
|
@@ -462,6 +462,11 @@ def _record_final_message(record):
|
|
|
462
462
|
"""The final assistant text one provider record carries, or None."""
|
|
463
463
|
if not isinstance(record, dict):
|
|
464
464
|
return None
|
|
465
|
+
if record.get("status") == "SUCCESS" and isinstance(record.get("response"), str):
|
|
466
|
+
return record["response"]
|
|
467
|
+
message = record.get("message")
|
|
468
|
+
if record.get("done") is True and isinstance(message, dict) and isinstance(message.get("content"), str):
|
|
469
|
+
return message["content"]
|
|
465
470
|
# Claude: `--output-format json` ends with a `result` envelope.
|
|
466
471
|
if record.get("type") == "result" and isinstance(record.get("result"), str):
|
|
467
472
|
return record["result"]
|