scriptit-cli 0.2.2__py3-none-any.whl
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.
- scriptit_cli/__init__.py +17 -0
- scriptit_cli/analytics.py +244 -0
- scriptit_cli/auth.py +553 -0
- scriptit_cli/client.py +42 -0
- scriptit_cli/commands.py +302 -0
- scriptit_cli/config.py +19 -0
- scriptit_cli/errors.py +21 -0
- scriptit_cli/main.py +184 -0
- scriptit_cli/output.py +79 -0
- scriptit_cli/remote.py +1306 -0
- scriptit_cli/remote_auth.py +464 -0
- scriptit_cli/update_check.py +104 -0
- scriptit_cli/util.py +84 -0
- scriptit_cli-0.2.2.dist-info/METADATA +599 -0
- scriptit_cli-0.2.2.dist-info/RECORD +18 -0
- scriptit_cli-0.2.2.dist-info/WHEEL +4 -0
- scriptit_cli-0.2.2.dist-info/entry_points.txt +2 -0
- scriptit_cli-0.2.2.dist-info/licenses/LICENSE +202 -0
scriptit_cli/remote.py
ADDED
|
@@ -0,0 +1,1306 @@
|
|
|
1
|
+
"""Remote transport for the workstation CLI.
|
|
2
|
+
|
|
3
|
+
``scriptit`` is a thin remote shell: verbs are forwarded verbatim into the
|
|
4
|
+
user's sandbox (the platform's session-shell API). Native output comes from
|
|
5
|
+
byte-cursor pages and completion from SSE; v1 uses SSE plus file recovery.
|
|
6
|
+
The in-sandbox CLI stays the single behavior surface — this client never
|
|
7
|
+
executes automation locally, so
|
|
8
|
+
every verb outside :data:`CLIENT_COMMANDS` is forwarded unconditionally.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import contextlib
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import posixpath
|
|
19
|
+
import queue
|
|
20
|
+
import re
|
|
21
|
+
import secrets
|
|
22
|
+
import shlex
|
|
23
|
+
import string
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
import requests
|
|
30
|
+
|
|
31
|
+
from scriptit_cli import __version__
|
|
32
|
+
from scriptit_cli.analytics import analytics
|
|
33
|
+
from scriptit_cli.client import detect_client
|
|
34
|
+
from scriptit_cli.config import _config_dir
|
|
35
|
+
from scriptit_cli.errors import RemoteAuthError, RemoteError, ScriptItError
|
|
36
|
+
from scriptit_cli.remote_auth import get_fresh_id_token, load_credentials
|
|
37
|
+
from scriptit_cli.util import update_json_file
|
|
38
|
+
|
|
39
|
+
STATE_FILE = "remote_state.json"
|
|
40
|
+
|
|
41
|
+
# Names the anchor session for one invocation, overriding the sticky one.
|
|
42
|
+
ENV_SESSION = "SCRIPTIT_SESSION"
|
|
43
|
+
|
|
44
|
+
# Verbs this client answers itself; everything else is forwarded into the
|
|
45
|
+
# sandbox and answered by the CLI running there.
|
|
46
|
+
CLIENT_COMMANDS = {
|
|
47
|
+
"auth",
|
|
48
|
+
"context",
|
|
49
|
+
"fs",
|
|
50
|
+
"session",
|
|
51
|
+
"sandbox",
|
|
52
|
+
"exec",
|
|
53
|
+
"version",
|
|
54
|
+
"--version",
|
|
55
|
+
"-V",
|
|
56
|
+
"help",
|
|
57
|
+
"--help",
|
|
58
|
+
"-h",
|
|
59
|
+
# Fire's argument separator, and what it tells users to type in its own
|
|
60
|
+
# help output ("Showing help with the command 'scriptit -- --help'").
|
|
61
|
+
# Forwarding it would answer that suggestion with a transport error.
|
|
62
|
+
"--",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# How long to wait out a 409 SESSION_BUSY before re-attempting dispatch.
|
|
67
|
+
_BUSY_RETRY_SECONDS = 3
|
|
68
|
+
|
|
69
|
+
# The v2 session-stream version this client reads: the bridge's frames and the
|
|
70
|
+
# native shell events matched below. Every v2 snapshot names the bridge's own
|
|
71
|
+
# (`WIRE_VERSION` in the platform's `sandbox/src/native/wire.ts`).
|
|
72
|
+
WIRE_VERSION = 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def make_message_id() -> str:
|
|
76
|
+
"""Message id, in the shape the session service expects: low 48 bits of
|
|
77
|
+
``ms * 0x1000 + counter`` as 12 hex chars, then 14 base62.
|
|
78
|
+
Messages are ordered lexicographically by id, so an id of a different
|
|
79
|
+
shape (or time scale) sorts into the wrong place.
|
|
80
|
+
"""
|
|
81
|
+
encoded = int(time.time() * 1000) * 0x1000 + 1
|
|
82
|
+
hex_part = format(encoded, "x").rjust(12, "0")[-12:]
|
|
83
|
+
alphabet = string.digits + string.ascii_uppercase + string.ascii_lowercase
|
|
84
|
+
rand = "".join(secrets.choice(alphabet) for _ in range(14))
|
|
85
|
+
return f"msg_{hex_part}{rand}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def apply_cwd(command: str, cwd: Optional[str]) -> str:
|
|
89
|
+
"""Run ``command`` from ``cwd``, failing rather than running elsewhere.
|
|
90
|
+
|
|
91
|
+
The guarded ``cd`` is part of the remote command, not a client-side path.
|
|
92
|
+
"""
|
|
93
|
+
if not cwd:
|
|
94
|
+
return command
|
|
95
|
+
return f"cd {shlex.quote(cwd)} || exit 1\n{command}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Bounded by the sandbox's own `timeout`, so the command dies there rather than
|
|
99
|
+
# outliving a client that gave up. Quoted heredoc: the payload reaches bash
|
|
100
|
+
# with no expansion and no quoting to get wrong, whatever the command contains.
|
|
101
|
+
_HEREDOC_TAG = "__SCRIPTIT_CMD_{token}__"
|
|
102
|
+
|
|
103
|
+
# What may appear in the trailing UI comment. No shell metacharacter is on
|
|
104
|
+
# this list, so nothing from the command can act once it is echoed back into
|
|
105
|
+
# the dispatched string.
|
|
106
|
+
_UI_COMMENT_ALLOWED = re.compile(r"[^A-Za-z0-9 ._/:@=+-]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _format_seconds(seconds: float) -> str:
|
|
110
|
+
"""Seconds for GNU ``timeout``, keeping sub-second values.
|
|
111
|
+
|
|
112
|
+
``int()`` would turn 0.5 into ``timeout 0``, which means *no limit* —
|
|
113
|
+
silently removing the bound the caller asked for.
|
|
114
|
+
"""
|
|
115
|
+
if seconds == int(seconds):
|
|
116
|
+
return str(int(seconds))
|
|
117
|
+
return f"{seconds:g}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _ui_comment(command: str) -> str:
|
|
121
|
+
"""A readable trailing comment naming what the payload runs.
|
|
122
|
+
|
|
123
|
+
The dispatched string is base64, which tells someone watching the session
|
|
124
|
+
in the app nothing. This puts the command's first line back where they can
|
|
125
|
+
read it, filtered to characters that cannot do anything.
|
|
126
|
+
"""
|
|
127
|
+
first = (command.strip().splitlines() or [""])[0]
|
|
128
|
+
safe = " ".join(_UI_COMMENT_ALLOWED.sub(" ", first).split())[:120]
|
|
129
|
+
return f" # {safe}" if safe else ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_shell_wrapper(
|
|
133
|
+
command: str,
|
|
134
|
+
sentinel: str,
|
|
135
|
+
log_path: str,
|
|
136
|
+
timeout_s: Optional[float] = None,
|
|
137
|
+
) -> str:
|
|
138
|
+
"""The `session/shell` dispatch string for ``command`` + exit ``sentinel``.
|
|
139
|
+
|
|
140
|
+
The session runtime executes shell commands as
|
|
141
|
+
``eval ${JSON.stringify(command)}`` inside a double-quoted bash -c
|
|
142
|
+
script, so the dispatched string undergoes
|
|
143
|
+
one round of double-quote expansion BEFORE eval: `$`, backticks and
|
|
144
|
+
newlines expand or collapse too early, `#` would comment out anything
|
|
145
|
+
appended, and a trailing ``\\`` folds the wrapper into the command.
|
|
146
|
+
|
|
147
|
+
Every command therefore rides a base64 payload piped into bash, where it
|
|
148
|
+
arrives verbatim and the real ``$?`` reaches the sentinel. An `&&`/`||`
|
|
149
|
+
sentinel would keep the command readable in the dispatched string, but it
|
|
150
|
+
can only report 0-or-1 — a tool whose exit 2 means something specific
|
|
151
|
+
would be indistinguishable from a plain failure. The exit code is the
|
|
152
|
+
contract; :func:`_ui_comment` recovers the readability instead.
|
|
153
|
+
|
|
154
|
+
Every dispatch also tees combined stdout+stderr into ``log_path``
|
|
155
|
+
(relative to the session's own cwd, and inside a subtree the file API
|
|
156
|
+
exposes): the platform event stream caps live text at ~8KB, so ``shell()``
|
|
157
|
+
falls back to reading this file whole when a command's real output
|
|
158
|
+
overflows it.
|
|
159
|
+
The sentinel is printed **inside** the group being tee'd, so the log is
|
|
160
|
+
a complete transcript — output *and* exit code — not just the part
|
|
161
|
+
``split_exit_sentinel`` can't score.
|
|
162
|
+
|
|
163
|
+
``timeout_s`` bounds the command sandbox-side; it exits 124 when the limit
|
|
164
|
+
is hit, and forces the base64 branch so that code is reported exactly.
|
|
165
|
+
"""
|
|
166
|
+
if timeout_s:
|
|
167
|
+
tag = _HEREDOC_TAG.format(token=secrets.token_hex(4))
|
|
168
|
+
inner = f"timeout {_format_seconds(timeout_s)} bash <<'{tag}'\n{command}\n{tag}"
|
|
169
|
+
else:
|
|
170
|
+
inner = command
|
|
171
|
+
# Subshell parens so a user `exit N` still reaches the sentinel, which
|
|
172
|
+
# reads the real exit code here (this shell is ours).
|
|
173
|
+
# Derived, never hardcoded: a `tee` into a directory that does not exist
|
|
174
|
+
# yet fails, and the two drifting apart would break recovery silently.
|
|
175
|
+
script = (
|
|
176
|
+
f"mkdir -p {posixpath.dirname(log_path)} 2>/dev/null\n"
|
|
177
|
+
f"(\n(\n{inner}\n) 2>&1\n"
|
|
178
|
+
f"printf '\\n{sentinel}%d\\n' \"$?\"\n"
|
|
179
|
+
f") | tee {log_path}"
|
|
180
|
+
)
|
|
181
|
+
encoded = base64.b64encode(script.encode("utf-8")).decode("ascii")
|
|
182
|
+
return f"printf %s {encoded} | base64 -d | bash{_ui_comment(command)}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def split_exit_sentinel(raw: str, sentinel: str) -> Tuple[str, Optional[int]]:
|
|
186
|
+
"""Split collected output into (visible output, exit code).
|
|
187
|
+
|
|
188
|
+
The exit code is ``None`` when the sentinel never arrived — e.g. the
|
|
189
|
+
stream cap truncated the tail — which callers must treat as unknown,
|
|
190
|
+
never success.
|
|
191
|
+
"""
|
|
192
|
+
idx = raw.rfind(sentinel)
|
|
193
|
+
if idx == -1:
|
|
194
|
+
return raw, None
|
|
195
|
+
# Leading contiguous digits only: late output (background-process noise
|
|
196
|
+
# flushed after the sentinel line) must not be folded into the code.
|
|
197
|
+
tail = raw[idx + len(sentinel) :].lstrip()
|
|
198
|
+
digits = ""
|
|
199
|
+
for ch in tail:
|
|
200
|
+
if not ch.isdigit():
|
|
201
|
+
break
|
|
202
|
+
digits += ch
|
|
203
|
+
exit_code = int(digits[:4]) if digits else None
|
|
204
|
+
return raw[:idx].rstrip("\n"), exit_code
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Client state (chosen sandbox + anchor session per api_url)
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _state_path() -> str:
|
|
213
|
+
return os.path.join(_config_dir(), STATE_FILE)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def load_state(state_key: str) -> Dict[str, Any]:
|
|
217
|
+
"""The persisted client state for one profile+platform (chosen sandbox,
|
|
218
|
+
anchor session), or ``{}``. Keyed by ``<profile>:<api_url>`` so two
|
|
219
|
+
accounts against the same platform never share a session."""
|
|
220
|
+
try:
|
|
221
|
+
with open(_state_path(), encoding="utf-8") as f:
|
|
222
|
+
all_state = json.load(f)
|
|
223
|
+
entry = all_state.get(state_key)
|
|
224
|
+
return entry if isinstance(entry, dict) else {}
|
|
225
|
+
except (OSError, ValueError):
|
|
226
|
+
return {}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def update_state(state_key: str, **fields: Any) -> None:
|
|
230
|
+
"""Merge ``fields`` into one profile+platform's state.
|
|
231
|
+
|
|
232
|
+
The merge happens *inside* the lock, against whatever is on disk right
|
|
233
|
+
then. Loading an entry, editing the copy and writing it back would hold a
|
|
234
|
+
stale snapshot across the gap — another process that changed a different
|
|
235
|
+
field in between would have its change reverted, which is how a freshly
|
|
236
|
+
chosen session goes missing after a sandbox update.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def _mutate(all_state: Dict[str, Any]) -> Dict[str, Any]:
|
|
240
|
+
entry = all_state.get(state_key)
|
|
241
|
+
entry = dict(entry) if isinstance(entry, dict) else {}
|
|
242
|
+
entry.update(fields)
|
|
243
|
+
all_state[state_key] = entry
|
|
244
|
+
return all_state
|
|
245
|
+
|
|
246
|
+
# 0600 like the credential store: this file carries live session ids.
|
|
247
|
+
update_json_file(_state_path(), _mutate)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# SSE parsing (minimal, requests-based; no extra dependency)
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _iter_sse_events(lines: Iterable[bytes]) -> Iterable[Tuple[str, str, Optional[str]]]:
|
|
256
|
+
"""Yield ``(event_name, data, id)`` frames from an SSE byte-line stream.
|
|
257
|
+
|
|
258
|
+
Handles multi-line ``data:`` fields and ignores comments/keepalives.
|
|
259
|
+
``event_name`` is ``"message"`` when the stream doesn't name events.
|
|
260
|
+
"""
|
|
261
|
+
event_name = "message"
|
|
262
|
+
data_lines: List[str] = []
|
|
263
|
+
event_id = None
|
|
264
|
+
for raw in lines:
|
|
265
|
+
line = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
|
266
|
+
if line == "":
|
|
267
|
+
if data_lines:
|
|
268
|
+
yield event_name, "\n".join(data_lines), event_id
|
|
269
|
+
event_name = "message"
|
|
270
|
+
data_lines = []
|
|
271
|
+
event_id = None
|
|
272
|
+
continue
|
|
273
|
+
if line.startswith(":"):
|
|
274
|
+
continue
|
|
275
|
+
if line.startswith("event:"):
|
|
276
|
+
event_name = line[len("event:") :].strip()
|
|
277
|
+
elif line.startswith("id:"):
|
|
278
|
+
event_id = line[len("id:") :].strip()
|
|
279
|
+
elif line.startswith("data:"):
|
|
280
|
+
# SSE spec: strip at most ONE leading space after the colon.
|
|
281
|
+
val = line[len("data:") :]
|
|
282
|
+
data_lines.append(val[1:] if val.startswith(" ") else val)
|
|
283
|
+
if data_lines:
|
|
284
|
+
yield event_name, "\n".join(data_lines), event_id
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _error_message(resp: requests.Response) -> str:
|
|
288
|
+
"""An error response's own message: its JSON `message`, `error` or
|
|
289
|
+
`detail`, else the start of its text."""
|
|
290
|
+
try:
|
|
291
|
+
body = resp.json()
|
|
292
|
+
except ValueError:
|
|
293
|
+
body = None
|
|
294
|
+
if isinstance(body, dict):
|
|
295
|
+
for key in ("message", "error", "detail"):
|
|
296
|
+
if isinstance(body.get(key), str):
|
|
297
|
+
return body[key]
|
|
298
|
+
return resp.text[:300]
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _wire_version(snapshot: Dict[str, Any]) -> Any:
|
|
302
|
+
wire = snapshot.get("wire")
|
|
303
|
+
return wire.get("version") if isinstance(wire, dict) else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class _SessionEvents:
|
|
307
|
+
"""One invocation's event listener; v2 reconnects resume its durable cursor."""
|
|
308
|
+
|
|
309
|
+
def __init__(self, client: RemoteClient, sandbox_id: str, session_id: str) -> None:
|
|
310
|
+
self.client = client
|
|
311
|
+
self.sandbox_id, self.session_id = sandbox_id, session_id
|
|
312
|
+
self.message_id = make_message_id()
|
|
313
|
+
self.url = f"{client.api_url}{client._proxy(sandbox_id, f'session/{session_id}/events')}"
|
|
314
|
+
self.group = f"cli-{secrets.token_hex(12)}"
|
|
315
|
+
self.protocol: Optional[int] = None
|
|
316
|
+
self.frames: queue.Queue = queue.Queue()
|
|
317
|
+
self.ready = threading.Event()
|
|
318
|
+
self.stop = threading.Event()
|
|
319
|
+
self.lock = threading.Lock()
|
|
320
|
+
self.armed = False
|
|
321
|
+
self.error: Optional[str] = None
|
|
322
|
+
self.response: Optional[requests.Response] = None
|
|
323
|
+
|
|
324
|
+
def __enter__(self) -> _SessionEvents:
|
|
325
|
+
threading.Thread(target=self._read, daemon=True).start()
|
|
326
|
+
return self
|
|
327
|
+
|
|
328
|
+
def __exit__(self, *_: Any) -> None:
|
|
329
|
+
self.stop.set()
|
|
330
|
+
response = self.response
|
|
331
|
+
if response is not None:
|
|
332
|
+
# requests.close can wait for the reader's socket lock. Cleanup
|
|
333
|
+
# must not delay a completed command or Ctrl-C until a heartbeat.
|
|
334
|
+
threading.Thread(target=response.close, daemon=True).start()
|
|
335
|
+
|
|
336
|
+
def arm(self) -> None:
|
|
337
|
+
# V1 has no resumable result stream. Check its liveness and start
|
|
338
|
+
# collecting in one critical section before issuing the POST.
|
|
339
|
+
with self.lock:
|
|
340
|
+
if self.error:
|
|
341
|
+
raise RemoteError(self.error)
|
|
342
|
+
self.armed = True
|
|
343
|
+
|
|
344
|
+
def disarm(self) -> None:
|
|
345
|
+
with self.lock:
|
|
346
|
+
self.armed = False
|
|
347
|
+
while not self.frames.empty():
|
|
348
|
+
self.frames.get_nowait()
|
|
349
|
+
|
|
350
|
+
def poll(self, timeout: float = 0.1) -> Optional[Tuple[str, Dict[str, Any]]]:
|
|
351
|
+
try:
|
|
352
|
+
name, payload = self.frames.get(timeout=timeout)
|
|
353
|
+
except queue.Empty:
|
|
354
|
+
return None
|
|
355
|
+
if name == "_stream_error":
|
|
356
|
+
raise RemoteError(payload["message"])
|
|
357
|
+
return name, payload
|
|
358
|
+
|
|
359
|
+
def _fail(self, message: str) -> None:
|
|
360
|
+
with self.lock:
|
|
361
|
+
self.error = message
|
|
362
|
+
self.frames.put(("_stream_error", {"message": message}))
|
|
363
|
+
self.ready.set()
|
|
364
|
+
|
|
365
|
+
def _read(self) -> None:
|
|
366
|
+
last_id = None
|
|
367
|
+
while not self.stop.is_set():
|
|
368
|
+
try:
|
|
369
|
+
headers = {**self.client._headers(), "x-scriptit-events-group": self.group}
|
|
370
|
+
if last_id is not None:
|
|
371
|
+
headers["Last-Event-ID"] = last_id
|
|
372
|
+
with self.client._http.get(
|
|
373
|
+
self.url, headers=headers, stream=True, timeout=(15, 60)
|
|
374
|
+
) as resp:
|
|
375
|
+
self.response = resp
|
|
376
|
+
if resp.status_code != 200:
|
|
377
|
+
message = f"events stream failed ({resp.status_code})"
|
|
378
|
+
if resp.status_code < 500:
|
|
379
|
+
self._fail(f"{message}: {_error_message(resp)}")
|
|
380
|
+
return
|
|
381
|
+
raise requests.RequestException(message)
|
|
382
|
+
for name, data, event_id in _iter_sse_events(
|
|
383
|
+
resp.iter_lines(decode_unicode=False)
|
|
384
|
+
):
|
|
385
|
+
if self.stop.is_set():
|
|
386
|
+
return
|
|
387
|
+
try:
|
|
388
|
+
payload = json.loads(data)
|
|
389
|
+
except ValueError:
|
|
390
|
+
continue
|
|
391
|
+
if not isinstance(payload, dict):
|
|
392
|
+
continue
|
|
393
|
+
if name == "session_snapshot" and _wire_version(payload) != WIRE_VERSION:
|
|
394
|
+
# Its shell events may not be the ones matched below,
|
|
395
|
+
# so waiting could only end at the command timeout.
|
|
396
|
+
self._fail(
|
|
397
|
+
"this sandbox's session stream is version "
|
|
398
|
+
f"{_wire_version(payload)} and scriptit-cli {__version__} "
|
|
399
|
+
f"reads version {WIRE_VERSION}: upgrade scriptit-cli"
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
if self.protocol is None and name in ("load_complete", "session_snapshot"):
|
|
403
|
+
self.protocol = 1 if name == "load_complete" else 2
|
|
404
|
+
self.ready.set()
|
|
405
|
+
if name == "error" and self.protocol != 1:
|
|
406
|
+
message = str(payload.get("message") or "session event stream failed")
|
|
407
|
+
if payload.get("retryable") is True:
|
|
408
|
+
raise requests.RequestException(message)
|
|
409
|
+
self._fail(message)
|
|
410
|
+
return
|
|
411
|
+
with self.lock:
|
|
412
|
+
if self.armed:
|
|
413
|
+
self.frames.put((name, payload))
|
|
414
|
+
if event_id is not None:
|
|
415
|
+
last_id = event_id
|
|
416
|
+
message = "events stream closed before the command reported back"
|
|
417
|
+
except requests.RequestException as exc:
|
|
418
|
+
message = f"events stream dropped: {exc}"
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
self._fail(f"events stream unexpected error: {exc!r}")
|
|
421
|
+
return
|
|
422
|
+
finally:
|
|
423
|
+
self.response = None
|
|
424
|
+
if self.protocol == 1:
|
|
425
|
+
self._fail(message)
|
|
426
|
+
return
|
|
427
|
+
self.stop.wait(1)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# Remote client
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class RemoteClient:
|
|
436
|
+
"""Authenticated client for the backend REST API + sandbox proxy."""
|
|
437
|
+
|
|
438
|
+
def __init__(self) -> None:
|
|
439
|
+
creds = load_credentials()
|
|
440
|
+
if not creds:
|
|
441
|
+
raise RemoteAuthError(
|
|
442
|
+
"not logged in — run `scriptit auth login` "
|
|
443
|
+
"(add `--api-url <origin>` for a self-hosted deployment)"
|
|
444
|
+
)
|
|
445
|
+
self.creds = creds
|
|
446
|
+
self.api_url = str(creds["api_url"]).rstrip("/")
|
|
447
|
+
# The browser origin of the same deployment, recorded at login. Only
|
|
448
|
+
# ever used to build links a human opens; empty when the deployment
|
|
449
|
+
# did not advertise one, in which case callers omit the link rather
|
|
450
|
+
# than guessing an origin.
|
|
451
|
+
self.app_url = str(creds.get("app_url") or "").rstrip("/")
|
|
452
|
+
self.profile = str(creds.get("profile") or "default")
|
|
453
|
+
# State (chosen sandbox, anchor session) is per profile+platform so
|
|
454
|
+
# switching accounts never reuses another account's session.
|
|
455
|
+
self.state_key = f"{self.profile}:{self.api_url}"
|
|
456
|
+
self._http = requests.Session()
|
|
457
|
+
self._cached_user_id: Optional[str] = None
|
|
458
|
+
# Usage events ride this same credential and deployment, so building a
|
|
459
|
+
# client is what makes reporting possible at all — a command that never
|
|
460
|
+
# authenticated reports nothing.
|
|
461
|
+
analytics.bind(self.api_url, lambda: get_fresh_id_token(self.creds))
|
|
462
|
+
|
|
463
|
+
# -- plumbing ----------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
def _headers(self) -> Dict[str, str]:
|
|
466
|
+
return {"Authorization": f"Bearer {get_fresh_id_token(self.creds)}"}
|
|
467
|
+
|
|
468
|
+
def request(
|
|
469
|
+
self,
|
|
470
|
+
method: str,
|
|
471
|
+
path: str,
|
|
472
|
+
*,
|
|
473
|
+
wake_on_503: bool = True,
|
|
474
|
+
timeout: float = 60.0,
|
|
475
|
+
**kwargs: Any,
|
|
476
|
+
) -> requests.Response:
|
|
477
|
+
"""One API call; only reads automatically retry sandbox resume 503s."""
|
|
478
|
+
url = f"{self.api_url}{path}"
|
|
479
|
+
retry = wake_on_503 and method.upper() in ("GET", "HEAD")
|
|
480
|
+
deadline = time.time() + 180 if retry else time.time()
|
|
481
|
+
while True:
|
|
482
|
+
try:
|
|
483
|
+
resp = self._http.request(
|
|
484
|
+
method, url, headers=self._headers(), timeout=timeout, **kwargs
|
|
485
|
+
)
|
|
486
|
+
except requests.RequestException as exc:
|
|
487
|
+
# DNS failure, refused connection, dropped link — surface as
|
|
488
|
+
# the CLI's own error type so every caller prints it cleanly.
|
|
489
|
+
raise RemoteError(f"API request failed: {exc}") from exc
|
|
490
|
+
if resp.status_code != 503 or time.time() >= deadline:
|
|
491
|
+
return resp
|
|
492
|
+
# Unread retry responses would sit on pool connections until GC.
|
|
493
|
+
resp.close()
|
|
494
|
+
# Sandbox paused/resuming/busy: nudge it awake and retry. GETs
|
|
495
|
+
# never auto-resume server-side, so the activity POST is the wake.
|
|
496
|
+
self.post_activity()
|
|
497
|
+
time.sleep(3)
|
|
498
|
+
|
|
499
|
+
def post_activity(self) -> None:
|
|
500
|
+
# Best-effort wake nudge: the caller polls readiness regardless.
|
|
501
|
+
with contextlib.suppress(requests.RequestException):
|
|
502
|
+
self._http.post(
|
|
503
|
+
f"{self.api_url}/api/v1/sandbox/activity",
|
|
504
|
+
headers=self._headers(),
|
|
505
|
+
timeout=30,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
def _own_user_id(self) -> str:
|
|
509
|
+
"""The caller's backend user id, cached — needed to build a session's
|
|
510
|
+
``/workspaces/user_<id>/.sessions/<sid>`` directory path. The id is
|
|
511
|
+
not derivable from the bearer token client-side, and Keycloak-mode
|
|
512
|
+
tokens carry no matching claim."""
|
|
513
|
+
cached = self._cached_user_id
|
|
514
|
+
if cached:
|
|
515
|
+
return cached
|
|
516
|
+
resp = self.request("GET", "/api/v1/auth/me", wake_on_503=False, timeout=30)
|
|
517
|
+
if resp.status_code != 200:
|
|
518
|
+
raise RemoteError(f"auth/me failed ({resp.status_code}): {resp.text[:300]}")
|
|
519
|
+
user_id = str(resp.json()["id"])
|
|
520
|
+
self._cached_user_id = user_id
|
|
521
|
+
return user_id
|
|
522
|
+
|
|
523
|
+
# -- sandbox lifecycle -------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def list_sandboxes(self) -> List[Dict[str, Any]]:
|
|
526
|
+
resp = self.request("GET", "/api/v1/sandbox/list", wake_on_503=False)
|
|
527
|
+
if resp.status_code != 200:
|
|
528
|
+
raise RemoteError(f"sandbox list failed ({resp.status_code}): {resp.text[:300]}")
|
|
529
|
+
return list(resp.json().get("sandboxes") or [])
|
|
530
|
+
|
|
531
|
+
def ensure_sandbox(self, timeout_s: float = 240.0) -> str:
|
|
532
|
+
"""A ready sandbox id — creating (via /sandbox/init) or waking as needed."""
|
|
533
|
+
state = load_state(self.state_key)
|
|
534
|
+
deadline = time.time() + timeout_s
|
|
535
|
+
|
|
536
|
+
sandboxes = self.list_sandboxes()
|
|
537
|
+
chosen: Optional[str] = None
|
|
538
|
+
if state.get("sandbox_id") and any(
|
|
539
|
+
s.get("sandbox_id") == state["sandbox_id"] for s in sandboxes
|
|
540
|
+
):
|
|
541
|
+
chosen = str(state["sandbox_id"])
|
|
542
|
+
elif sandboxes:
|
|
543
|
+
active = next((s for s in sandboxes if s.get("is_active")), None)
|
|
544
|
+
chosen = str((active or sandboxes[0])["sandbox_id"])
|
|
545
|
+
|
|
546
|
+
# /sandbox/init is the ensure-ready endpoint: it auto-creates a
|
|
547
|
+
# sandbox for a fresh user and reports resume progress for a paused
|
|
548
|
+
# one. Poll it until ready either way.
|
|
549
|
+
while True:
|
|
550
|
+
resp = self.request("GET", "/api/v1/sandbox/init", wake_on_503=False, timeout=30)
|
|
551
|
+
body: Dict[str, Any] = {}
|
|
552
|
+
if resp.headers.get("content-type", "").startswith("application/json"):
|
|
553
|
+
body = resp.json()
|
|
554
|
+
# 4xx is terminal (bad credentials, no access) — fail now instead
|
|
555
|
+
# of polling out the full deadline. 5xx stays retryable: resume
|
|
556
|
+
# churn can surface transient server errors, and 429 asks to wait.
|
|
557
|
+
if 400 <= resp.status_code < 500 and resp.status_code != 429:
|
|
558
|
+
raise RemoteError(f"sandbox init failed ({resp.status_code}): {resp.text[:300]}")
|
|
559
|
+
status = body.get("status")
|
|
560
|
+
if resp.status_code == 200 and status == "ready":
|
|
561
|
+
break
|
|
562
|
+
if time.time() >= deadline:
|
|
563
|
+
raise RemoteError(
|
|
564
|
+
f"sandbox not ready after {int(timeout_s)}s (last status: {status or resp.status_code})"
|
|
565
|
+
)
|
|
566
|
+
self.post_activity()
|
|
567
|
+
time.sleep(max(1.0, float(body.get("retry_after_ms") or 3000) / 1000.0))
|
|
568
|
+
|
|
569
|
+
if not chosen:
|
|
570
|
+
sandboxes = self.list_sandboxes()
|
|
571
|
+
if not sandboxes:
|
|
572
|
+
raise RemoteError("no sandbox available after init")
|
|
573
|
+
active = next((s for s in sandboxes if s.get("is_active")), None)
|
|
574
|
+
chosen = str((active or sandboxes[0])["sandbox_id"])
|
|
575
|
+
|
|
576
|
+
update_state(self.state_key, sandbox_id=chosen)
|
|
577
|
+
return chosen
|
|
578
|
+
|
|
579
|
+
# -- anchor session ----------------------------------------------------
|
|
580
|
+
|
|
581
|
+
def _proxy(self, sandbox_id: str, path: str) -> str:
|
|
582
|
+
return f"/api/v1/sandbox/{sandbox_id}/proxy/{path}"
|
|
583
|
+
|
|
584
|
+
def agent_context(self, sandbox_id: str, context_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
585
|
+
"""Return the complete sandbox-assembled external-agent context."""
|
|
586
|
+
resp = self.request(
|
|
587
|
+
"POST",
|
|
588
|
+
self._proxy(sandbox_id, "agent-context"),
|
|
589
|
+
json=context_data,
|
|
590
|
+
wake_on_503=False,
|
|
591
|
+
timeout=30,
|
|
592
|
+
)
|
|
593
|
+
if resp.status_code != 200:
|
|
594
|
+
raise RemoteError(f"agent context unavailable ({resp.status_code}): {resp.text[:300]}")
|
|
595
|
+
try:
|
|
596
|
+
body = resp.json()
|
|
597
|
+
except ValueError as exc:
|
|
598
|
+
raise RemoteError("agent context returned invalid JSON") from exc
|
|
599
|
+
if not isinstance(body, dict) or body.get("schema_version") != 1:
|
|
600
|
+
raise RemoteError("agent context returned an unsupported schema")
|
|
601
|
+
if not isinstance(body.get("markdown"), str) or not body["markdown"].strip():
|
|
602
|
+
raise RemoteError("agent context omitted its rendered instructions")
|
|
603
|
+
return body
|
|
604
|
+
|
|
605
|
+
def session_exists(self, sandbox_id: str, session_id: str) -> bool:
|
|
606
|
+
resp = self.request(
|
|
607
|
+
"GET", self._proxy(sandbox_id, f"session/{session_id}/exists"), timeout=30
|
|
608
|
+
)
|
|
609
|
+
return resp.status_code == 200 and bool(resp.json().get("exists"))
|
|
610
|
+
|
|
611
|
+
def ensure_session_attribution(
|
|
612
|
+
self,
|
|
613
|
+
sandbox_id: str,
|
|
614
|
+
session_id: str,
|
|
615
|
+
client_tag: str = "cli",
|
|
616
|
+
) -> Optional[str]:
|
|
617
|
+
"""Idempotently attribute a bound session to this CLI and harness."""
|
|
618
|
+
detected = detect_client()
|
|
619
|
+
state = load_state(self.state_key)
|
|
620
|
+
persisted = state.get("client") if state.get("session_id") == session_id else None
|
|
621
|
+
client = detected or (persisted if isinstance(persisted, str) and persisted else None)
|
|
622
|
+
tags = [client_tag] + ([f"client:{client}"] if client else [])
|
|
623
|
+
resp = self.request(
|
|
624
|
+
"POST",
|
|
625
|
+
self._proxy(sandbox_id, f"session/{session_id}/tags"),
|
|
626
|
+
json={"add": tags},
|
|
627
|
+
timeout=30,
|
|
628
|
+
)
|
|
629
|
+
if resp.status_code != 200:
|
|
630
|
+
raise RemoteError(f"session attribution failed ({resp.status_code}): {resp.text[:300]}")
|
|
631
|
+
return client
|
|
632
|
+
|
|
633
|
+
def create_session(self, sandbox_id: str, client_tag: str = "cli") -> str:
|
|
634
|
+
# Initialize is idempotent; retry through agent-boot races (425).
|
|
635
|
+
last = ""
|
|
636
|
+
for _ in range(6):
|
|
637
|
+
resp = self.request("POST", self._proxy(sandbox_id, "initialize"), json={}, timeout=60)
|
|
638
|
+
if resp.status_code == 200:
|
|
639
|
+
break
|
|
640
|
+
last = f"{resp.status_code}: {resp.text[:200]}"
|
|
641
|
+
time.sleep(3)
|
|
642
|
+
else:
|
|
643
|
+
raise RemoteError(f"sandbox agent initialize failed ({last})")
|
|
644
|
+
# A v1 bridge answers with the ACP initialize result and requires the
|
|
645
|
+
# ACP session fields; a v2 bridge derives the session itself and
|
|
646
|
+
# refuses unknown fields.
|
|
647
|
+
v1 = "protocolVersion" in resp.json()
|
|
648
|
+
new_session_body = {"cwd": "/workspaces", "mcpServers": []} if v1 else {}
|
|
649
|
+
|
|
650
|
+
for _ in range(4):
|
|
651
|
+
resp = self.request(
|
|
652
|
+
"POST",
|
|
653
|
+
self._proxy(sandbox_id, "session/new"),
|
|
654
|
+
json=new_session_body,
|
|
655
|
+
timeout=60,
|
|
656
|
+
)
|
|
657
|
+
if resp.status_code == 200:
|
|
658
|
+
body = resp.json()
|
|
659
|
+
session_id = body.get("sessionId") or (body.get("session") or {}).get("id")
|
|
660
|
+
if session_id:
|
|
661
|
+
break
|
|
662
|
+
elif 400 <= resp.status_code < 500:
|
|
663
|
+
raise RemoteError(
|
|
664
|
+
f"session/new failed ({resp.status_code}): {_error_message(resp)}"
|
|
665
|
+
)
|
|
666
|
+
time.sleep(2)
|
|
667
|
+
else:
|
|
668
|
+
raise RemoteError("session/new failed")
|
|
669
|
+
|
|
670
|
+
# Attribute the session before returning it so the app never silently
|
|
671
|
+
# loses which external harness is driving. The display title remains
|
|
672
|
+
# cosmetic and may fail without invalidating the usable session.
|
|
673
|
+
client = self.ensure_session_attribution(sandbox_id, str(session_id), client_tag)
|
|
674
|
+
title = f"CLI remote session ({client})" if client else "CLI remote session"
|
|
675
|
+
with contextlib.suppress(ScriptItError, requests.RequestException):
|
|
676
|
+
# RemoteError too: `request` wraps transport failures in it, so
|
|
677
|
+
# catching only RequestException would let a cosmetic call fail
|
|
678
|
+
# session creation after the session already exists. wake_on_503
|
|
679
|
+
# off for the same reason — this must not spend three minutes
|
|
680
|
+
# retrying a title.
|
|
681
|
+
self.request(
|
|
682
|
+
"PATCH",
|
|
683
|
+
self._proxy(sandbox_id, f"session/{session_id}"),
|
|
684
|
+
json={"title": title},
|
|
685
|
+
wake_on_503=False,
|
|
686
|
+
timeout=30,
|
|
687
|
+
)
|
|
688
|
+
return str(session_id)
|
|
689
|
+
|
|
690
|
+
def anchor(self) -> Tuple[Optional[str], Optional[str], str]:
|
|
691
|
+
"""The anchor session this invocation runs in, and where it came from.
|
|
692
|
+
|
|
693
|
+
``SCRIPTIT_SESSION`` names one for a single invocation — how a harness
|
|
694
|
+
gives a task its own transcript — and otherwise the sticky anchor for
|
|
695
|
+
this profile+platform is used.
|
|
696
|
+
"""
|
|
697
|
+
env_session = os.environ.get(ENV_SESSION, "").strip()
|
|
698
|
+
if env_session:
|
|
699
|
+
# No sandbox recorded: a named session is resolved against
|
|
700
|
+
# whichever sandbox the account is using.
|
|
701
|
+
return env_session, None, "env"
|
|
702
|
+
state = load_state(self.state_key)
|
|
703
|
+
if state.get("session_id"):
|
|
704
|
+
return str(state["session_id"]), state.get("sandbox_id"), "state"
|
|
705
|
+
return None, None, "none"
|
|
706
|
+
|
|
707
|
+
def ensure_session(self, sandbox_id: str) -> str:
|
|
708
|
+
session_id, _, source = self.anchor()
|
|
709
|
+
if session_id and self.session_exists(sandbox_id, str(session_id)):
|
|
710
|
+
client = self.ensure_session_attribution(sandbox_id, str(session_id))
|
|
711
|
+
if source == "state" and client:
|
|
712
|
+
update_state(self.state_key, client=client)
|
|
713
|
+
return str(session_id)
|
|
714
|
+
if source == "env":
|
|
715
|
+
# An explicitly named session that does not exist is a mistake
|
|
716
|
+
# worth reporting, not something to silently replace.
|
|
717
|
+
raise RemoteError(
|
|
718
|
+
f"session {session_id} (from {ENV_SESSION}) not found in sandbox {sandbox_id}"
|
|
719
|
+
)
|
|
720
|
+
session_id = self.create_session(sandbox_id)
|
|
721
|
+
update_state(
|
|
722
|
+
self.state_key,
|
|
723
|
+
sandbox_id=sandbox_id,
|
|
724
|
+
session_id=session_id,
|
|
725
|
+
client=detect_client(),
|
|
726
|
+
)
|
|
727
|
+
return session_id
|
|
728
|
+
|
|
729
|
+
# -- shell over session/shell + SSE ------------------------------------
|
|
730
|
+
|
|
731
|
+
# V1 compatibility: its SSE sanitizer caps every text field at 8000 chars.
|
|
732
|
+
_SSE_TEXT_CAP = 8000
|
|
733
|
+
# The V1 session runtime discards the child's exit code, so the CLI
|
|
734
|
+
# appends a sentinel line and parses it out of the collected output. Each shell() call mints
|
|
735
|
+
# its own random-suffixed sentinel so command output can't spoof it.
|
|
736
|
+
_EXIT_SENTINEL_PREFIX = "__SCRIPTIT_CLI_EXIT_"
|
|
737
|
+
# Every dispatch tees its combined output here (relative to the session's
|
|
738
|
+
# own cwd) so output the ~8KB live cap swallowed can still be read back.
|
|
739
|
+
# One fixed name, not one per command: `tee` truncates on open and the
|
|
740
|
+
# server serializes session ops, so the file always holds the current
|
|
741
|
+
# command — and a session accumulates one log, not one per dispatch.
|
|
742
|
+
# A stale read is self-detecting anyway: the sentinel is per-call random,
|
|
743
|
+
# so a previous command's log simply scores as "no exit code".
|
|
744
|
+
#
|
|
745
|
+
# The run dir rather than `data_files/`, though both are exposed by the
|
|
746
|
+
# file API: a block's `files_written` is a before/after diff of the data
|
|
747
|
+
# dir, so a log written there is reported as a file the script wrote.
|
|
748
|
+
_LOG_PATH = "scriptit/.scriptit-cli-last.log"
|
|
749
|
+
|
|
750
|
+
def shell(
|
|
751
|
+
self,
|
|
752
|
+
command: str,
|
|
753
|
+
*,
|
|
754
|
+
sandbox_id: Optional[str] = None,
|
|
755
|
+
session_id: Optional[str] = None,
|
|
756
|
+
timeout_s: float = 600.0,
|
|
757
|
+
echo: bool = True,
|
|
758
|
+
cwd: Optional[str] = None,
|
|
759
|
+
command_timeout_s: Optional[float] = None,
|
|
760
|
+
) -> Tuple[str, Optional[int]]:
|
|
761
|
+
"""Select the session's wire protocol before submitting one command."""
|
|
762
|
+
sandbox_id = sandbox_id or self.ensure_sandbox()
|
|
763
|
+
session_id = session_id or self.ensure_session(sandbox_id)
|
|
764
|
+
command = apply_cwd(command, cwd)
|
|
765
|
+
with _SessionEvents(self, sandbox_id, session_id) as events:
|
|
766
|
+
if not events.ready.wait(timeout=60):
|
|
767
|
+
raise RemoteError("timed out waiting for session event stream bootstrap")
|
|
768
|
+
if events.error:
|
|
769
|
+
raise RemoteError(events.error)
|
|
770
|
+
if events.protocol == 1:
|
|
771
|
+
return self._shell_v1(
|
|
772
|
+
command,
|
|
773
|
+
events,
|
|
774
|
+
timeout_s,
|
|
775
|
+
echo,
|
|
776
|
+
command_timeout_s,
|
|
777
|
+
)
|
|
778
|
+
if command_timeout_s:
|
|
779
|
+
command = (
|
|
780
|
+
f"timeout {_format_seconds(command_timeout_s)} bash -c {shlex.quote(command)}"
|
|
781
|
+
)
|
|
782
|
+
return self._shell_v2(command, events, timeout_s, echo)
|
|
783
|
+
|
|
784
|
+
def _submit_shell(self, command: str, events: _SessionEvents) -> Optional[Dict[str, Any]]:
|
|
785
|
+
"""Retry only a refusal that proves this command was not submitted.
|
|
786
|
+
|
|
787
|
+
An ambiguous v2 response leaves collection running: the matching start
|
|
788
|
+
or completed row can still establish the command's outcome.
|
|
789
|
+
"""
|
|
790
|
+
deadline = time.monotonic() + 60
|
|
791
|
+
while True:
|
|
792
|
+
events.arm()
|
|
793
|
+
try:
|
|
794
|
+
resp = self.request(
|
|
795
|
+
"POST",
|
|
796
|
+
self._proxy(events.sandbox_id, f"session/{events.session_id}/shell"),
|
|
797
|
+
json={"command": command, "message_id": events.message_id},
|
|
798
|
+
wake_on_503=False,
|
|
799
|
+
timeout=60,
|
|
800
|
+
)
|
|
801
|
+
except RemoteError:
|
|
802
|
+
if events.protocol == 2:
|
|
803
|
+
return None
|
|
804
|
+
raise
|
|
805
|
+
if resp.status_code in (200, 202) and events.protocol == 1:
|
|
806
|
+
return None
|
|
807
|
+
try:
|
|
808
|
+
body = resp.json()
|
|
809
|
+
except ValueError:
|
|
810
|
+
body = {}
|
|
811
|
+
if not isinstance(body, dict):
|
|
812
|
+
body = {}
|
|
813
|
+
if resp.status_code in (200, 202):
|
|
814
|
+
shell = body.get("shell")
|
|
815
|
+
if body.get("message_id") == events.message_id and isinstance(shell, dict):
|
|
816
|
+
return shell
|
|
817
|
+
return None
|
|
818
|
+
detail = body
|
|
819
|
+
while isinstance(detail.get("detail"), dict):
|
|
820
|
+
detail = detail["detail"]
|
|
821
|
+
code = detail.get("code")
|
|
822
|
+
refused = (resp.status_code == 409 and code == "SESSION_BUSY") or (
|
|
823
|
+
resp.status_code == 503
|
|
824
|
+
and (
|
|
825
|
+
code in {"SHELL_NOT_SUBMITTED", "SANDBOX_RESUMING", "SANDBOX_UPDATING"}
|
|
826
|
+
or detail.get("submission_state") == "not_submitted"
|
|
827
|
+
)
|
|
828
|
+
)
|
|
829
|
+
if refused and time.monotonic() < deadline:
|
|
830
|
+
retry_ms = detail.get("retry_after_ms")
|
|
831
|
+
delay = (
|
|
832
|
+
max(0, retry_ms / 1000)
|
|
833
|
+
if isinstance(retry_ms, (int, float))
|
|
834
|
+
else _BUSY_RETRY_SECONDS
|
|
835
|
+
)
|
|
836
|
+
resp.close()
|
|
837
|
+
events.disarm()
|
|
838
|
+
time.sleep(min(delay, max(0, deadline - time.monotonic())))
|
|
839
|
+
continue
|
|
840
|
+
if (
|
|
841
|
+
events.protocol == 2
|
|
842
|
+
and resp.status_code >= 500
|
|
843
|
+
and not refused
|
|
844
|
+
and code not in ("SHELL_NOT_SUBMITTED", "SESSION_MATERIALIZE_FAILED")
|
|
845
|
+
and detail.get("submission_state") != "not_submitted"
|
|
846
|
+
):
|
|
847
|
+
return None
|
|
848
|
+
raise RemoteError(f"shell dispatch failed ({resp.status_code}): {resp.text[:300]}")
|
|
849
|
+
|
|
850
|
+
def _shell_v2(
|
|
851
|
+
self,
|
|
852
|
+
command: str,
|
|
853
|
+
events: _SessionEvents,
|
|
854
|
+
timeout_s: float,
|
|
855
|
+
echo: bool,
|
|
856
|
+
) -> Tuple[str, Optional[int]]:
|
|
857
|
+
shell = self._submit_shell(command, events)
|
|
858
|
+
shell_id = shell.get("id") if shell else None
|
|
859
|
+
result: Optional[Dict[str, Any]] = None
|
|
860
|
+
cursor = 0
|
|
861
|
+
chunks: List[str] = []
|
|
862
|
+
deadline = time.monotonic() + timeout_s
|
|
863
|
+
|
|
864
|
+
def append(text: str) -> None:
|
|
865
|
+
chunks.append(text)
|
|
866
|
+
if echo:
|
|
867
|
+
sys.stdout.write(text)
|
|
868
|
+
sys.stdout.flush()
|
|
869
|
+
|
|
870
|
+
def observe(name: str, payload: Dict[str, Any]) -> None:
|
|
871
|
+
nonlocal shell_id, result
|
|
872
|
+
if name == "session_snapshot":
|
|
873
|
+
for row in payload.get("rows", []):
|
|
874
|
+
if row.get("type") != "shell" or row.get("id") != events.message_id:
|
|
875
|
+
continue
|
|
876
|
+
shell_id = row.get("shellID")
|
|
877
|
+
if row.get("status") != "running":
|
|
878
|
+
result = row
|
|
879
|
+
elif name == "session_event":
|
|
880
|
+
kind = payload.get("type")
|
|
881
|
+
if kind not in ("session.shell.started", "session.shell.ended"):
|
|
882
|
+
return
|
|
883
|
+
data = payload.get("data") or {}
|
|
884
|
+
info = data.get("shell") or {}
|
|
885
|
+
own_message = (info.get("metadata") or {}).get("messageID") == events.message_id
|
|
886
|
+
if not own_message and (not shell_id or info.get("id") != shell_id):
|
|
887
|
+
return
|
|
888
|
+
shell_id = info.get("id")
|
|
889
|
+
if kind == "session.shell.ended":
|
|
890
|
+
result = {**info, "output": data.get("output")}
|
|
891
|
+
|
|
892
|
+
while True:
|
|
893
|
+
frame = events.poll(0)
|
|
894
|
+
while frame is not None:
|
|
895
|
+
observe(*frame)
|
|
896
|
+
frame = events.poll(0)
|
|
897
|
+
if time.monotonic() >= deadline:
|
|
898
|
+
if result is not None:
|
|
899
|
+
raise RemoteError("complete shell output could not be recovered")
|
|
900
|
+
raise RemoteError(
|
|
901
|
+
"command completion could not be confirmed; it may still be running. "
|
|
902
|
+
"Check `scriptit session current` before re-running."
|
|
903
|
+
)
|
|
904
|
+
if shell_id:
|
|
905
|
+
try:
|
|
906
|
+
resp = self.request(
|
|
907
|
+
"GET",
|
|
908
|
+
self._proxy(
|
|
909
|
+
events.sandbox_id,
|
|
910
|
+
f"session/{events.session_id}/shell/{shell_id}/output",
|
|
911
|
+
),
|
|
912
|
+
params={"cursor": cursor, "limit": 65536},
|
|
913
|
+
wake_on_503=False,
|
|
914
|
+
timeout=min(30, max(0.1, deadline - time.monotonic())),
|
|
915
|
+
)
|
|
916
|
+
except RemoteError:
|
|
917
|
+
resp = None
|
|
918
|
+
if resp is None or resp.status_code >= 500 or resp.status_code in (408, 429):
|
|
919
|
+
# Reads can resume from the same cursor after a network
|
|
920
|
+
# drop; submission is never repeated to recover output.
|
|
921
|
+
frame = events.poll(min(0.5, max(0, deadline - time.monotonic())))
|
|
922
|
+
if frame is not None:
|
|
923
|
+
observe(*frame)
|
|
924
|
+
continue
|
|
925
|
+
if resp.status_code == 404:
|
|
926
|
+
if result is not None:
|
|
927
|
+
saved = result.get("output")
|
|
928
|
+
if (
|
|
929
|
+
not isinstance(saved, dict)
|
|
930
|
+
or saved.get("truncated") is not False
|
|
931
|
+
or type(saved.get("size")) is not int
|
|
932
|
+
or saved.get("cursor") != saved["size"]
|
|
933
|
+
or not isinstance(saved.get("output"), str)
|
|
934
|
+
):
|
|
935
|
+
raise RemoteError("complete shell output is no longer available")
|
|
936
|
+
collected = "".join(chunks)
|
|
937
|
+
if not saved["output"].startswith(collected):
|
|
938
|
+
raise RemoteError(
|
|
939
|
+
"persisted shell output does not match collected output"
|
|
940
|
+
)
|
|
941
|
+
append(saved["output"][len(collected) :])
|
|
942
|
+
break
|
|
943
|
+
elif resp.status_code == 200:
|
|
944
|
+
try:
|
|
945
|
+
page = resp.json()
|
|
946
|
+
except ValueError as exc:
|
|
947
|
+
raise RemoteError("shell output returned invalid JSON") from exc
|
|
948
|
+
if (
|
|
949
|
+
not isinstance(page, dict)
|
|
950
|
+
or not isinstance(page.get("output"), str)
|
|
951
|
+
or type(page.get("cursor")) is not int
|
|
952
|
+
or type(page.get("size")) is not int
|
|
953
|
+
or not cursor <= page["cursor"] <= page["size"]
|
|
954
|
+
or page.get("truncated") is not False
|
|
955
|
+
):
|
|
956
|
+
raise RemoteError("shell output returned an incomplete page")
|
|
957
|
+
next_cursor = page["cursor"]
|
|
958
|
+
if page["output"] and next_cursor == cursor:
|
|
959
|
+
raise RemoteError("shell output did not advance its byte cursor")
|
|
960
|
+
if next_cursor > cursor and not page["output"]:
|
|
961
|
+
raise RemoteError("shell output omitted captured bytes")
|
|
962
|
+
append(page["output"])
|
|
963
|
+
advanced = next_cursor > cursor
|
|
964
|
+
cursor = next_cursor
|
|
965
|
+
if result is not None and cursor == page["size"]:
|
|
966
|
+
saved = result.get("output")
|
|
967
|
+
saved = saved if isinstance(saved, dict) else {}
|
|
968
|
+
if isinstance(saved.get("size"), int) and cursor < saved["size"]:
|
|
969
|
+
raise RemoteError("shell output ended before its recorded size")
|
|
970
|
+
break
|
|
971
|
+
if cursor < page["size"]:
|
|
972
|
+
if advanced:
|
|
973
|
+
continue
|
|
974
|
+
if result is not None:
|
|
975
|
+
raise RemoteError("shell output ended before its recorded size")
|
|
976
|
+
else:
|
|
977
|
+
raise RemoteError(
|
|
978
|
+
f"shell output failed ({resp.status_code}): {resp.text[:300]}"
|
|
979
|
+
)
|
|
980
|
+
frame = events.poll(0.1)
|
|
981
|
+
if frame is not None:
|
|
982
|
+
observe(*frame)
|
|
983
|
+
|
|
984
|
+
output = "".join(chunks)
|
|
985
|
+
status = result.get("status")
|
|
986
|
+
exit_code = result.get("exit")
|
|
987
|
+
if status not in ("exited", "timeout", "killed"):
|
|
988
|
+
exit_code = None
|
|
989
|
+
elif type(exit_code) is not int or (exit_code == 0 and status != "exited"):
|
|
990
|
+
exit_code = {"timeout": 124, "killed": 130}.get(status)
|
|
991
|
+
return output, exit_code
|
|
992
|
+
|
|
993
|
+
def _shell_v1(
|
|
994
|
+
self,
|
|
995
|
+
command: str,
|
|
996
|
+
events: _SessionEvents,
|
|
997
|
+
timeout_s: float,
|
|
998
|
+
echo: bool,
|
|
999
|
+
command_timeout_s: Optional[float],
|
|
1000
|
+
) -> Tuple[str, Optional[int]]:
|
|
1001
|
+
"""Compatibility with load_complete/tool_call_update sandbox streams."""
|
|
1002
|
+
sentinel = f"{self._EXIT_SENTINEL_PREFIX}{secrets.token_hex(8)}__:"
|
|
1003
|
+
wrapped = build_shell_wrapper(command, sentinel, self._LOG_PATH, command_timeout_s)
|
|
1004
|
+
self._submit_shell(wrapped, events)
|
|
1005
|
+
buffers: Dict[str, str] = {}
|
|
1006
|
+
holdback = len(sentinel) + 16
|
|
1007
|
+
printed_len = 0
|
|
1008
|
+
deadline = time.monotonic() + timeout_s
|
|
1009
|
+
while True:
|
|
1010
|
+
frame = events.poll()
|
|
1011
|
+
if frame is not None:
|
|
1012
|
+
name, payload = frame
|
|
1013
|
+
if name == "session_update":
|
|
1014
|
+
update = payload.get("update") or {}
|
|
1015
|
+
call_id = update.get("toolCallId")
|
|
1016
|
+
content = update.get("content")
|
|
1017
|
+
if call_id and update.get("sessionUpdate") == "tool_call":
|
|
1018
|
+
buffers.setdefault(call_id, "")
|
|
1019
|
+
elif call_id and isinstance(content, list):
|
|
1020
|
+
buffers[call_id] = "".join(
|
|
1021
|
+
(item.get("content") or {}).get("text", "")
|
|
1022
|
+
for item in content
|
|
1023
|
+
if isinstance(item, dict)
|
|
1024
|
+
and item.get("type") == "content"
|
|
1025
|
+
and isinstance(item.get("content"), dict)
|
|
1026
|
+
)
|
|
1027
|
+
elif (
|
|
1028
|
+
name == "message_complete"
|
|
1029
|
+
and payload.get("parentMessageId") == events.message_id
|
|
1030
|
+
):
|
|
1031
|
+
break
|
|
1032
|
+
elif (
|
|
1033
|
+
name == "error"
|
|
1034
|
+
and payload.get("parentMessageId") == events.message_id
|
|
1035
|
+
and payload.get("op") == "shell"
|
|
1036
|
+
):
|
|
1037
|
+
raise RemoteError(str(payload.get("message") or "shell dispatch failed"))
|
|
1038
|
+
if echo:
|
|
1039
|
+
text = "".join(buffers.values())
|
|
1040
|
+
visible = max(0, len(text) - holdback)
|
|
1041
|
+
if visible > printed_len:
|
|
1042
|
+
sys.stdout.write(text[printed_len:visible])
|
|
1043
|
+
sys.stdout.flush()
|
|
1044
|
+
printed_len = visible
|
|
1045
|
+
if time.monotonic() >= deadline:
|
|
1046
|
+
raise RemoteError(
|
|
1047
|
+
f"timed out after {int(timeout_s)}s waiting for command completion"
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
raw = "".join(buffers.values())
|
|
1051
|
+
output, exit_code = split_exit_sentinel(raw, sentinel)
|
|
1052
|
+
truncated = exit_code is None and len(raw) >= self._SSE_TEXT_CAP - 100
|
|
1053
|
+
if truncated:
|
|
1054
|
+
try:
|
|
1055
|
+
full_path = f"{self.session_dir(events.session_id)}/{self._LOG_PATH}"
|
|
1056
|
+
recovered_raw = self.fs_read(events.sandbox_id, full_path).decode(
|
|
1057
|
+
"utf-8", errors="replace"
|
|
1058
|
+
)
|
|
1059
|
+
recovered_output, recovered_code = split_exit_sentinel(recovered_raw, sentinel)
|
|
1060
|
+
if recovered_code is not None:
|
|
1061
|
+
output, exit_code = recovered_output, recovered_code
|
|
1062
|
+
truncated = False
|
|
1063
|
+
except ScriptItError:
|
|
1064
|
+
pass
|
|
1065
|
+
if echo:
|
|
1066
|
+
sys.stdout.write(output[printed_len:])
|
|
1067
|
+
if output and not output.endswith("\n"):
|
|
1068
|
+
sys.stdout.write("\n")
|
|
1069
|
+
if truncated:
|
|
1070
|
+
sys.stdout.write(
|
|
1071
|
+
"[scriptit: live output truncated at ~8KB by the platform event "
|
|
1072
|
+
"stream — redirect to a file and use `scriptit fs read` for full output]\n"
|
|
1073
|
+
)
|
|
1074
|
+
sys.stdout.flush()
|
|
1075
|
+
return output, exit_code
|
|
1076
|
+
|
|
1077
|
+
# -- files -------------------------------------------------------------
|
|
1078
|
+
|
|
1079
|
+
def session_dir(self, session_id: str) -> str:
|
|
1080
|
+
"""A session's working directory as the file API addresses it."""
|
|
1081
|
+
return f"/workspaces/user_{self._own_user_id()}/.sessions/{session_id}"
|
|
1082
|
+
|
|
1083
|
+
def resolve_fs_path(self, sandbox_id: str, path: str) -> str:
|
|
1084
|
+
"""The absolute path the file API takes for an ``fs`` path in ``sandbox_id``.
|
|
1085
|
+
|
|
1086
|
+
A relative path starts at the anchor session's directory, the cwd
|
|
1087
|
+
``exec`` runs in, so a path that works in ``exec`` names the same
|
|
1088
|
+
file here. The session's ``workspaces/<id>`` is the canonical
|
|
1089
|
+
``/workspaces/<id>`` tree mounted in place, and the file API takes
|
|
1090
|
+
only the canonical form, so those paths resolve to it without a
|
|
1091
|
+
session.
|
|
1092
|
+
|
|
1093
|
+
The anchor must still exist in this sandbox. ``exec`` replaces one
|
|
1094
|
+
that is gone with a new session; a file command does not start one,
|
|
1095
|
+
so it stops here rather than reach into a session ``exec`` will no
|
|
1096
|
+
longer run in.
|
|
1097
|
+
"""
|
|
1098
|
+
if path.startswith("/"):
|
|
1099
|
+
return path
|
|
1100
|
+
relative = posixpath.normpath(path)
|
|
1101
|
+
if relative == "workspaces" or relative.startswith("workspaces/"):
|
|
1102
|
+
return f"/{relative}"
|
|
1103
|
+
session_id, _, source = self.anchor()
|
|
1104
|
+
if not session_id:
|
|
1105
|
+
raise RemoteError(
|
|
1106
|
+
f"no current session to resolve {path!r} against; run "
|
|
1107
|
+
"`scriptit session new` first, or give an absolute path"
|
|
1108
|
+
)
|
|
1109
|
+
if not self.session_exists(sandbox_id, session_id):
|
|
1110
|
+
if source == "env":
|
|
1111
|
+
raise RemoteError(
|
|
1112
|
+
f"session {session_id} (from {ENV_SESSION}) not found in sandbox {sandbox_id}"
|
|
1113
|
+
)
|
|
1114
|
+
raise RemoteError(
|
|
1115
|
+
f"the current session {session_id} no longer exists; run "
|
|
1116
|
+
"`scriptit session new` first, or give an absolute path"
|
|
1117
|
+
)
|
|
1118
|
+
return posixpath.normpath(posixpath.join(self.session_dir(session_id), relative))
|
|
1119
|
+
|
|
1120
|
+
def fs_read(self, sandbox_id: str, path: str) -> bytes:
|
|
1121
|
+
"""Read a sandbox file's bytes (base64 over the wire, decoded here)."""
|
|
1122
|
+
resp = self.request(
|
|
1123
|
+
"GET", f"/api/v1/file/{sandbox_id}/read", params={"path": path}, timeout=120
|
|
1124
|
+
)
|
|
1125
|
+
if resp.status_code == 404:
|
|
1126
|
+
raise RemoteError(f"not found: {path}")
|
|
1127
|
+
if resp.status_code != 200:
|
|
1128
|
+
raise RemoteError(f"read failed ({resp.status_code}): {resp.text[:300]}")
|
|
1129
|
+
return base64.b64decode(resp.json().get("content_base64") or "")
|
|
1130
|
+
|
|
1131
|
+
def fs_write(self, sandbox_id: str, path: str, content: str) -> None:
|
|
1132
|
+
"""Write text content to a sandbox file (parents auto-created)."""
|
|
1133
|
+
resp = self.request(
|
|
1134
|
+
"POST",
|
|
1135
|
+
f"/api/v1/file/{sandbox_id}/write",
|
|
1136
|
+
json={"path": path, "content": content},
|
|
1137
|
+
timeout=120,
|
|
1138
|
+
)
|
|
1139
|
+
if resp.status_code != 200:
|
|
1140
|
+
raise RemoteError(f"write failed ({resp.status_code}): {resp.text[:300]}")
|
|
1141
|
+
|
|
1142
|
+
def fs_upload(self, sandbox_id: str, path: str, local_file: str) -> None:
|
|
1143
|
+
"""Upload a local file into the sandbox (multipart, binary-safe)."""
|
|
1144
|
+
with open(local_file, "rb") as f:
|
|
1145
|
+
resp = self.request(
|
|
1146
|
+
"POST",
|
|
1147
|
+
f"/api/v1/file/{sandbox_id}/upload",
|
|
1148
|
+
data={"path": path},
|
|
1149
|
+
files={"file": (os.path.basename(local_file), f)},
|
|
1150
|
+
timeout=300,
|
|
1151
|
+
)
|
|
1152
|
+
if resp.status_code != 200:
|
|
1153
|
+
raise RemoteError(f"upload failed ({resp.status_code}): {resp.text[:300]}")
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
# ---------------------------------------------------------------------------
|
|
1157
|
+
# main() dispatch hook
|
|
1158
|
+
# ---------------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def remote_dispatch_if_applicable(argv: List[str]) -> Optional[int]:
|
|
1162
|
+
"""Forward the invocation into the sandbox.
|
|
1163
|
+
|
|
1164
|
+
Returns an exit code when the invocation was handled remotely (or refused),
|
|
1165
|
+
or ``None`` for a verb this client answers itself.
|
|
1166
|
+
"""
|
|
1167
|
+
if not argv or argv[0] in CLIENT_COMMANDS:
|
|
1168
|
+
return None
|
|
1169
|
+
|
|
1170
|
+
if load_credentials() is None:
|
|
1171
|
+
sys.stderr.write(
|
|
1172
|
+
"scriptit: not connected to a sandbox. This command runs inside your "
|
|
1173
|
+
"Script.it sandbox; run `scriptit auth login` first.\n"
|
|
1174
|
+
)
|
|
1175
|
+
return 2
|
|
1176
|
+
|
|
1177
|
+
try:
|
|
1178
|
+
client = RemoteClient()
|
|
1179
|
+
command = "scriptit " + shlex.join(argv)
|
|
1180
|
+
_, exit_code = client.shell(command)
|
|
1181
|
+
return exit_code if exit_code is not None else _unknown_exit()
|
|
1182
|
+
except ScriptItError as exc:
|
|
1183
|
+
sys.stderr.write(f"scriptit: {exc}\n")
|
|
1184
|
+
return 1
|
|
1185
|
+
except KeyboardInterrupt:
|
|
1186
|
+
return _interrupted_exit()
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
# Exit status when the command ran but its real status is unknowable. Unknown is
|
|
1190
|
+
# NOT success — callers driving the CLI must not proceed as if it passed.
|
|
1191
|
+
UNKNOWN_EXIT_CODE = 125
|
|
1192
|
+
|
|
1193
|
+
# Ctrl-C, or an agent harness enforcing a per-command timeout. Shell
|
|
1194
|
+
# convention for SIGINT; also NOT success.
|
|
1195
|
+
INTERRUPTED_EXIT_CODE = 130
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _interrupted_exit() -> int:
|
|
1199
|
+
"""Report an interrupt as one line, not a stack trace.
|
|
1200
|
+
|
|
1201
|
+
Dispatch is fire-and-forget, so the command keeps running in the sandbox
|
|
1202
|
+
after the local process goes away — say so, since the next thing the
|
|
1203
|
+
caller does is usually re-run it.
|
|
1204
|
+
"""
|
|
1205
|
+
sys.stderr.write(
|
|
1206
|
+
"scriptit: interrupted — the command may still be running in your "
|
|
1207
|
+
"sandbox. Check `scriptit session current` / `scriptit list` before "
|
|
1208
|
+
"re-running.\n"
|
|
1209
|
+
)
|
|
1210
|
+
return INTERRUPTED_EXIT_CODE
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _unknown_exit() -> int:
|
|
1214
|
+
sys.stderr.write(
|
|
1215
|
+
f"scriptit: command completed but its exit status is unknown — exiting {UNKNOWN_EXIT_CODE}; "
|
|
1216
|
+
"check the session before re-running\n"
|
|
1217
|
+
)
|
|
1218
|
+
return UNKNOWN_EXIT_CODE
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _parse_timeout(value: str) -> float:
|
|
1222
|
+
"""Seconds from a flag value, or a message the user can act on.
|
|
1223
|
+
|
|
1224
|
+
Rejects the values that would otherwise reach the sandbox as a broken
|
|
1225
|
+
limit: a non-number, zero or negative (GNU ``timeout 0`` means no limit),
|
|
1226
|
+
and ``nan``/``inf``, which parse as floats and then blow up at format
|
|
1227
|
+
time.
|
|
1228
|
+
"""
|
|
1229
|
+
try:
|
|
1230
|
+
seconds = float(value)
|
|
1231
|
+
except ValueError:
|
|
1232
|
+
raise RemoteError(f"--timeout expects seconds, got {value!r}") from None
|
|
1233
|
+
if not math.isfinite(seconds):
|
|
1234
|
+
raise RemoteError(f"--timeout expects a finite number of seconds, got {value!r}")
|
|
1235
|
+
if seconds <= 0:
|
|
1236
|
+
raise RemoteError("--timeout must be greater than zero")
|
|
1237
|
+
return seconds
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _parse_exec_options(argv: List[str]) -> Tuple[List[str], Optional[str], Optional[float], bool]:
|
|
1241
|
+
"""Split client flags off the front of ``exec``'s arguments.
|
|
1242
|
+
|
|
1243
|
+
Only what precedes ``--`` is inspected, so the command keeps its own
|
|
1244
|
+
``--cwd``/``--timeout`` untouched.
|
|
1245
|
+
"""
|
|
1246
|
+
args = list(argv)
|
|
1247
|
+
cwd: Optional[str] = None
|
|
1248
|
+
timeout: Optional[float] = None
|
|
1249
|
+
flags = {"--cwd": "cwd", "--timeout": "timeout"}
|
|
1250
|
+
while args and args[0] != "--":
|
|
1251
|
+
token = args[0]
|
|
1252
|
+
if token in ("--help", "-h"):
|
|
1253
|
+
return [], cwd, timeout, True
|
|
1254
|
+
name = flags.get(token)
|
|
1255
|
+
if name is not None:
|
|
1256
|
+
# `--` is the separator, never a value: consuming it would make
|
|
1257
|
+
# the flag swallow it and the real command run with a bogus
|
|
1258
|
+
# setting. A missing value would otherwise run the flag itself.
|
|
1259
|
+
if len(args) < 2 or args[1] == "--":
|
|
1260
|
+
raise RemoteError(f"{token} needs a value")
|
|
1261
|
+
value, args = args[1], args[2:]
|
|
1262
|
+
elif "=" in token and flags.get(token.split("=", 1)[0]) is not None:
|
|
1263
|
+
head, value = token.split("=", 1)
|
|
1264
|
+
name, args = flags[head], args[1:]
|
|
1265
|
+
else:
|
|
1266
|
+
break
|
|
1267
|
+
if name == "cwd":
|
|
1268
|
+
if not value:
|
|
1269
|
+
raise RemoteError("--cwd needs a directory")
|
|
1270
|
+
cwd = value
|
|
1271
|
+
else:
|
|
1272
|
+
timeout = _parse_timeout(value)
|
|
1273
|
+
if args and args[0] == "--":
|
|
1274
|
+
args = args[1:]
|
|
1275
|
+
return args, cwd, timeout, False
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def remote_exec(argv: List[str]) -> int:
|
|
1279
|
+
"""``scriptit exec [--cwd DIR] [--timeout SECS] [--] <cmd...>``."""
|
|
1280
|
+
try:
|
|
1281
|
+
args, cwd, command_timeout, help_requested = _parse_exec_options(argv)
|
|
1282
|
+
except RemoteError as exc:
|
|
1283
|
+
sys.stderr.write(f"scriptit: {exc}\n")
|
|
1284
|
+
return 2
|
|
1285
|
+
if help_requested or not args:
|
|
1286
|
+
stream = sys.stdout if help_requested else sys.stderr
|
|
1287
|
+
stream.write("usage: scriptit exec [--cwd DIR] [--timeout SECS] -- <command...>\n")
|
|
1288
|
+
if help_requested:
|
|
1289
|
+
stream.write("Run a shell command in the current sandbox session.\n")
|
|
1290
|
+
return 0
|
|
1291
|
+
return 2
|
|
1292
|
+
try:
|
|
1293
|
+
client = RemoteClient()
|
|
1294
|
+
command = args[0] if len(args) == 1 else shlex.join(args)
|
|
1295
|
+
# Outlive the sandbox-side limit so its 124 is what surfaces, not a
|
|
1296
|
+
# client that stopped listening just before it arrived.
|
|
1297
|
+
wait_s = command_timeout + 30 if command_timeout else 600.0
|
|
1298
|
+
_, exit_code = client.shell(
|
|
1299
|
+
command, cwd=cwd, command_timeout_s=command_timeout, timeout_s=wait_s
|
|
1300
|
+
)
|
|
1301
|
+
return exit_code if exit_code is not None else _unknown_exit()
|
|
1302
|
+
except ScriptItError as exc:
|
|
1303
|
+
sys.stderr.write(f"scriptit: {exc}\n")
|
|
1304
|
+
return 1
|
|
1305
|
+
except KeyboardInterrupt:
|
|
1306
|
+
return _interrupted_exit()
|