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/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()