cdx-manager 0.25.7 → 0.25.9
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 +31 -6
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_25_8.md +15 -0
- package/changelogs/CHANGELOGS_0_25_9.md +22 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +33 -1
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +69 -0
- package/docs/operations.md +39 -0
- package/docs/sessions-and-notifications.md +1 -1
- 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 +2 -0
- package/src/cli_args.py +2 -0
- package/src/commands/auth.py +25 -0
- package/src/commands/maintenance.py +4 -1
- package/src/commands/runs.py +16 -3
- 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 +4 -0
- package/src/session_status.py +16 -0
- package/src/status_view.py +1 -0
- package/src/update_all.py +9 -3
- package/src/workspace_sandbox.py +147 -14
package/src/commands/auth.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import sys
|
|
6
7
|
import time
|
|
7
8
|
import uuid
|
|
8
9
|
|
|
@@ -53,6 +54,30 @@ def _refresh_result(session, diagnostic):
|
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
def handle_auth(rest, ctx):
|
|
57
|
+
if rest and rest[0] == "claude-token":
|
|
58
|
+
json_flag, args = _parse_json_flag(rest[1:])
|
|
59
|
+
if len(args) != 1:
|
|
60
|
+
raise CdxError("Usage: cdx auth claude-token <profile> [--json]")
|
|
61
|
+
session = ctx["service"]["get_session"](args[0])
|
|
62
|
+
if not session or session["provider"] != PROVIDER_CLAUDE:
|
|
63
|
+
raise CdxError("Select an existing Claude profile for cdx auth claude-token.")
|
|
64
|
+
if ctx["stdin_is_tty"]:
|
|
65
|
+
raise CdxError("Read the Claude setup token from standard input, without an interactive terminal.")
|
|
66
|
+
from ..claude_remote_token import store_token
|
|
67
|
+
stream = ctx.get("prompt_stdin") or sys.stdin
|
|
68
|
+
token = stream.read(16385)
|
|
69
|
+
if isinstance(token, bytes):
|
|
70
|
+
try:
|
|
71
|
+
token = token.decode("utf-8")
|
|
72
|
+
except UnicodeDecodeError as error:
|
|
73
|
+
raise CdxError("Provide one Claude setup token on standard input.") from error
|
|
74
|
+
store_token(session, token)
|
|
75
|
+
message = f"Stored remote Claude setup token for {session['name']}"
|
|
76
|
+
if json_flag:
|
|
77
|
+
_write_json(ctx, _json_success("auth.claude-token", message))
|
|
78
|
+
else:
|
|
79
|
+
ctx["out"](message + "\n")
|
|
80
|
+
return 0
|
|
56
81
|
parsed = _parse_auth_args(rest)
|
|
57
82
|
target = parsed["target"]
|
|
58
83
|
if target == "all":
|
|
@@ -53,6 +53,8 @@ def _format_update_all_result(result, use_color=False):
|
|
|
53
53
|
return _warn(f"skipped: {result['name']} (blocked by {result['blocked_by']})", use_color)
|
|
54
54
|
if result.get("returncode") == 0:
|
|
55
55
|
versions = f" ({result['version_before']} -> {result['version_after']})" if "version_after" in result else ""
|
|
56
|
+
if result.get("warning"):
|
|
57
|
+
return _warn(f"done: {result['name']}{versions}\n {result['warning']['message']}", use_color)
|
|
56
58
|
return _success(f"done: {result['name']}{versions}", use_color)
|
|
57
59
|
detail = str(result.get("stderr") or result.get("stdout") or "No error output.").strip().replace("\n", " ")
|
|
58
60
|
detail = detail[-500:]
|
|
@@ -709,7 +711,8 @@ def handle_update(rest, ctx):
|
|
|
709
711
|
failed = [row for row in results if row.get("returncode") != 0]
|
|
710
712
|
message = "Applied workstation update plan" if not failed else "Workstation update plan completed with failures"
|
|
711
713
|
if parsed["json"]:
|
|
712
|
-
|
|
714
|
+
warnings = [dict(row["warning"], name=row["name"]) for row in results if row.get("warning")]
|
|
715
|
+
_write_json(ctx, _json_success("update.all", message, warnings=warnings, plan=plan, results=results, failed=len(failed)))
|
|
713
716
|
else:
|
|
714
717
|
ctx["out"](f"{_success(message, ctx['use_color']) if not failed else _warn(message, ctx['use_color'])}\n")
|
|
715
718
|
return 1 if failed else 0
|
package/src/commands/runs.py
CHANGED
|
@@ -713,16 +713,26 @@ def handle_run(rest, ctx):
|
|
|
713
713
|
session = selected["session"]
|
|
714
714
|
selection_row = selected.get("row") or {}
|
|
715
715
|
|
|
716
|
+
if parsed.get("remote") and session["provider"] == "antigravity":
|
|
717
|
+
parsed["isolate_workspace"] = True
|
|
716
718
|
remote_version = None
|
|
717
719
|
if parsed.get("remote"):
|
|
718
|
-
if session["provider"]
|
|
719
|
-
raise CdxError("--remote is only available for Codex profiles.", code="remote_provider_unsupported")
|
|
720
|
+
if session["provider"] not in ("codex", "claude", "ollama", "antigravity"):
|
|
721
|
+
raise CdxError("--remote is only available for Codex, Claude, Ollama and Antigravity profiles.", code="remote_provider_unsupported")
|
|
720
722
|
if parsed.get("continue_run"):
|
|
721
723
|
raise CdxError("--remote requires a fresh run without --continue-run.")
|
|
724
|
+
if session["provider"] == "ollama" and not (parsed.get("model") or (session.get("launch") or {}).get("model")):
|
|
725
|
+
raise CdxError("Remote Ollama text generation requires --model or a configured local model.")
|
|
726
|
+
if session["provider"] == "claude":
|
|
727
|
+
from ..claude_remote_token import read_token
|
|
728
|
+
read_token(session)
|
|
729
|
+
parsed["isolate_workspace"] = True
|
|
722
730
|
remote_version = preflight(
|
|
723
731
|
parsed["remote"], {**os.environ, **(ctx.get("env") or {})},
|
|
724
732
|
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"
|
|
733
|
+
cwd=parsed["cwd"], network=parsed.get("network_access", False), writable=parsed.get("permission") != "review",
|
|
734
|
+
**({"provider": "ollama", "model": parsed.get("model") or (session.get("launch") or {}).get("model")}
|
|
735
|
+
if session["provider"] == "ollama" else {"provider": session["provider"]}))
|
|
726
736
|
cwd = os.path.abspath(parsed["cwd"])
|
|
727
737
|
if not parsed.get("remote") and not os.path.isdir(cwd):
|
|
728
738
|
raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
|
|
@@ -1074,6 +1084,9 @@ def handle_run(rest, ctx):
|
|
|
1074
1084
|
message = f"Every eligible session was rate limited: {', '.join(attempted)}."
|
|
1075
1085
|
elif failover_error_code == "failover_limit_reached":
|
|
1076
1086
|
message = f"Failover stopped after {MAX_FAILOVER_TRANSITIONS} transitions: {', '.join(attempted)}."
|
|
1087
|
+
elif run_info.get("provider_error"):
|
|
1088
|
+
failover_error_code = run_info["provider_error"]
|
|
1089
|
+
message = run_info["provider_message"]
|
|
1077
1090
|
elif run_info.get("timed_out"):
|
|
1078
1091
|
message = "Provider process timed out."
|
|
1079
1092
|
else:
|
package/src/interactive_usage.py
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
"""Best-effort usage readers for interactive provider sessions.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- Antigravity has no retrievable source. Its conversation history is
|
|
10
|
-
schema-less binary protobuf under `~/.gemini/antigravity-cli/conversations/`
|
|
11
|
-
with no `.proto` available to decode field numbers into token counts, and its
|
|
12
|
-
logs are plain glog with no usage-bearing line at any verbosity `agy --help`
|
|
13
|
-
exposes. This was investigated and is a dead end, not an oversight -- do not
|
|
14
|
-
spend the afternoon rediscovering it.
|
|
3
|
+
- Claude and Codex expose per-session token counts in JSONL transcripts.
|
|
4
|
+
- Ollama exposes counts in the verbose terminal output captured by cdx.
|
|
5
|
+
- Antigravity exposes quota through interactive statusLine stdin (collected by
|
|
6
|
+
antigravity_status), and per-run tokens through `agy -p --output-format json`
|
|
7
|
+
(read by run_usage). Interactive per-run token counts remain unavailable;
|
|
8
|
+
quota changes are not token measurements.
|
|
15
9
|
"""
|
|
16
10
|
|
|
17
11
|
import json
|
|
@@ -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
|