runtime-sdk 0.4.8__tar.gz → 0.4.10__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.8
3
+ Version: 0.4.10
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.8"
3
+ version = "0.4.10"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,12 +5,18 @@ import contextlib
5
5
  import json
6
6
  import os
7
7
  import select
8
+ import shlex
8
9
  import shutil
10
+ import signal
11
+ import socket
12
+ import subprocess
9
13
  import sys
10
14
  import termios
11
15
  import threading
16
+ import time
12
17
  import tty
13
18
  from datetime import datetime, timezone
19
+ from pathlib import Path
14
20
  from typing import Any, Callable
15
21
  from urllib.parse import urljoin, urlparse
16
22
 
@@ -177,6 +183,9 @@ def build_parser() -> argparse.ArgumentParser:
177
183
  info_cmd = subparsers.add_parser("info", help="Get computer details")
178
184
  info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
179
185
 
186
+ url_cmd = subparsers.add_parser("url", help="Show a computer's public URL and live published port")
187
+ url_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
188
+
180
189
  start_cmd = subparsers.add_parser("start", help="Wake a cold computer")
181
190
  start_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
182
191
 
@@ -203,6 +212,23 @@ def build_parser() -> argparse.ArgumentParser:
203
212
  publish_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
204
213
  publish_cmd.add_argument("port", nargs="?", type=int, default=None, help="Local app port")
205
214
 
215
+ proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
216
+ proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
217
+ proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
218
+ proxy_start.add_argument("id", help="Computer ID (hostname)")
219
+ proxy_start.add_argument("ports", nargs="+", help="Port mappings like 5432 or 15432:5432")
220
+ proxy_subparsers.add_parser("ls", help="List background local port proxies")
221
+ proxy_stop = proxy_subparsers.add_parser("stop", help="Stop one or more background local port proxies")
222
+ proxy_stop.add_argument("local_ports", nargs="*", type=int, help="Local ports to stop")
223
+ proxy_stop.add_argument("--all", action="store_true", help="Stop all running proxies")
224
+
225
+ proxy_agent = subparsers.add_parser("_proxy_agent", help=argparse.SUPPRESS)
226
+ proxy_agent.add_argument("--base-url", required=True)
227
+ proxy_agent.add_argument("--computer-id", required=True)
228
+ proxy_agent.add_argument("--display-name", required=True)
229
+ proxy_agent.add_argument("--local-port", type=int, required=True)
230
+ proxy_agent.add_argument("--remote-port", type=int, required=True)
231
+
206
232
  return parser
207
233
 
208
234
 
@@ -247,6 +273,8 @@ def main(argv: list[str] | None = None) -> int:
247
273
  return handle_list(config)
248
274
  if args.command == "info":
249
275
  return handle_info(config, args.id)
276
+ if args.command == "url":
277
+ return handle_url(config, args.id)
250
278
  if args.command == "start":
251
279
  return handle_start(config, args.id)
252
280
  if args.command == "enter":
@@ -259,6 +287,23 @@ def main(argv: list[str] | None = None) -> int:
259
287
  )
260
288
  if args.command == "publish":
261
289
  return handle_publish(config, args.id, args.port)
290
+ if args.command == "proxy":
291
+ if args.proxy_command == "start":
292
+ return handle_proxy_start(config, args.id, args.ports)
293
+ if args.proxy_command == "ls":
294
+ return handle_proxy_ls(config)
295
+ if args.proxy_command == "stop":
296
+ return handle_proxy_stop(config, args.local_ports, stop_all=args.all)
297
+ return report_error("unknown proxy command")
298
+ if args.command == "_proxy_agent":
299
+ return run_proxy_agent(
300
+ args.base_url,
301
+ os.environ.get("RUNTIME_PROXY_API_KEY", ""),
302
+ args.computer_id,
303
+ args.display_name,
304
+ args.local_port,
305
+ args.remote_port,
306
+ )
262
307
  except RuntimeAPIError as exc:
263
308
  return report_error(str(exc), status_code=exc.status_code)
264
309
  except RuntimeConfigError as exc:
@@ -311,6 +356,201 @@ def _require_api_key(config: RuntimeConfig) -> int | None:
311
356
  return report_error("missing api key; run login or verify first")
312
357
 
313
358
 
359
+ def _runtime_state_dir() -> Path:
360
+ root = os.environ.get("XDG_STATE_HOME")
361
+ if root:
362
+ return Path(root) / "runtime"
363
+ return Path.home() / ".local" / "state" / "runtime"
364
+
365
+
366
+ def _proxy_dir() -> Path:
367
+ return _runtime_state_dir() / "proxies"
368
+
369
+
370
+ def _proxy_state_path(local_port: int) -> Path:
371
+ return _proxy_dir() / f"proxy-{local_port}.json"
372
+
373
+
374
+ def _proxy_log_path(local_port: int) -> Path:
375
+ return _proxy_dir() / f"proxy-{local_port}.log"
376
+
377
+
378
+ def _pid_running(pid: int) -> bool:
379
+ if pid <= 0:
380
+ return False
381
+ try:
382
+ os.kill(pid, 0)
383
+ return True
384
+ except OSError:
385
+ return False
386
+
387
+
388
+ def _pid_command(pid: int) -> str:
389
+ if pid <= 0:
390
+ return ""
391
+ try:
392
+ return subprocess.check_output(
393
+ ["ps", "-o", "command=", "-p", str(pid)],
394
+ text=True,
395
+ stderr=subprocess.DEVNULL,
396
+ ).strip()
397
+ except (OSError, subprocess.SubprocessError):
398
+ return ""
399
+
400
+
401
+ def _proxy_pid_matches(pid: int, local_port: int) -> bool:
402
+ if not _pid_running(pid):
403
+ return False
404
+ command = _pid_command(pid)
405
+ if not command:
406
+ return False
407
+ with contextlib.suppress(ValueError):
408
+ argv = shlex.split(command)
409
+ if "_proxy_agent" not in argv:
410
+ return False
411
+ if "--local-port" not in argv:
412
+ return False
413
+ index = argv.index("--local-port")
414
+ if index + 1 >= len(argv) or argv[index + 1] != str(local_port):
415
+ return False
416
+ return "runtime_sdk.cli" in argv or any(part.endswith("runtime_sdk.cli") for part in argv)
417
+ return False
418
+
419
+
420
+ def _load_proxy_records(*, clean_stale: bool = False) -> list[dict[str, Any]]:
421
+ records: list[dict[str, Any]] = []
422
+ directory = _proxy_dir()
423
+ if not directory.exists():
424
+ return records
425
+ for path in sorted(directory.glob("proxy-*.json")):
426
+ try:
427
+ record = json.loads(path.read_text())
428
+ except (OSError, ValueError):
429
+ continue
430
+ if not isinstance(record, dict):
431
+ continue
432
+ record["state_path"] = str(path)
433
+ pid = int(record.get("pid") or 0)
434
+ local_port = int(record.get("local_port") or 0)
435
+ record["status"] = "running" if _proxy_pid_matches(pid, local_port) else "stopped"
436
+ if clean_stale and record["status"] != "running":
437
+ with contextlib.suppress(OSError):
438
+ path.unlink()
439
+ continue
440
+ records.append(record)
441
+ return records
442
+
443
+
444
+ def _parse_proxy_port_spec(spec: str) -> tuple[int, int]:
445
+ text = str(spec or "").strip()
446
+ if not text:
447
+ raise RuntimeAPIError("port mapping is required")
448
+ if ":" in text:
449
+ local_text, remote_text = text.split(":", 1)
450
+ else:
451
+ local_text, remote_text = text, text
452
+ try:
453
+ local_port = int(local_text)
454
+ remote_port = int(remote_text)
455
+ except ValueError as exc:
456
+ raise RuntimeAPIError(f"invalid port mapping: {text}") from exc
457
+ for port in (local_port, remote_port):
458
+ if port <= 0 or port > 65535:
459
+ raise RuntimeAPIError("port must be between 1 and 65535")
460
+ return local_port, remote_port
461
+
462
+
463
+ def _resolve_ws_url(base_url: str, ws_url: Any) -> str:
464
+ text = str(ws_url or "").strip()
465
+ if not text:
466
+ return ""
467
+ if text.startswith("ws://") or text.startswith("wss://"):
468
+ return text
469
+
470
+ parsed = urlparse(base_url)
471
+ scheme = "wss" if parsed.scheme == "https" else "ws"
472
+ root = f"{scheme}://{parsed.netloc}"
473
+ if text.startswith("/"):
474
+ return root + text
475
+ return urljoin(root + "/", text)
476
+
477
+
478
+ def _spawn_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
479
+ proxy_dir = _proxy_dir()
480
+ proxy_dir.mkdir(parents=True, exist_ok=True)
481
+ log_path = _proxy_log_path(local_port)
482
+ log_file = log_path.open("a", encoding="utf-8")
483
+ child_env = os.environ.copy()
484
+ child_env["RUNTIME_PROXY_API_KEY"] = api_key
485
+ try:
486
+ proc = subprocess.Popen(
487
+ [
488
+ sys.executable,
489
+ "-m",
490
+ "runtime_sdk.cli",
491
+ "_proxy_agent",
492
+ "--base-url",
493
+ base_url,
494
+ "--computer-id",
495
+ computer_id,
496
+ "--display-name",
497
+ display_name,
498
+ "--local-port",
499
+ str(local_port),
500
+ "--remote-port",
501
+ str(remote_port),
502
+ ],
503
+ stdin=subprocess.DEVNULL,
504
+ stdout=log_file,
505
+ stderr=log_file,
506
+ start_new_session=True,
507
+ close_fds=True,
508
+ env=child_env,
509
+ )
510
+ finally:
511
+ log_file.close()
512
+
513
+ deadline = time.time() + 4.0
514
+ state_path = _proxy_state_path(local_port)
515
+ while time.time() < deadline:
516
+ if state_path.exists():
517
+ return proc.pid
518
+ code = proc.poll()
519
+ if code is not None:
520
+ break
521
+ time.sleep(0.1)
522
+
523
+ message = f"proxy failed to start on localhost:{local_port}"
524
+ with contextlib.suppress(OSError):
525
+ tail = log_path.read_text(encoding="utf-8").strip()
526
+ if tail:
527
+ message = tail.splitlines()[-1]
528
+ raise RuntimeAPIError(message)
529
+
530
+
531
+ def _remove_proxy_state(local_port: int) -> None:
532
+ with contextlib.suppress(OSError):
533
+ _proxy_state_path(local_port).unlink()
534
+
535
+
536
+ def _stop_proxy_ports(local_ports: list[int], *, stop_all: bool = False) -> list[dict[str, Any]]:
537
+ records = _load_proxy_records(clean_stale=False)
538
+ stopped: list[dict[str, Any]] = []
539
+ wanted = set(local_ports)
540
+ for record in records:
541
+ port = int(record.get("local_port") or 0)
542
+ if not stop_all and port not in wanted:
543
+ continue
544
+ pid = int(record.get("pid") or 0)
545
+ if _proxy_pid_matches(pid, port):
546
+ with contextlib.suppress(OSError):
547
+ os.kill(pid, signal.SIGTERM)
548
+ _remove_proxy_state(port)
549
+ record["status"] = "stopped"
550
+ stopped.append(record)
551
+ return stopped
552
+
553
+
314
554
  def _prompt_text(message: str, *, default: str | None = None) -> str | None:
315
555
  """Ask for a line of text. Returns None if the user left it blank.
316
556
 
@@ -732,6 +972,33 @@ def _render_run_result(result: dict[str, Any]) -> None:
732
972
  console.print(f"[{color}]exit {exit_code}[/{color}]")
733
973
 
734
974
 
975
+ def _render_network_panel(payload: dict[str, Any], *, title: str = "url") -> None:
976
+ mods = _UI.rich()
977
+ Panel, Text = mods["Panel"], mods["Text"]
978
+ body = Text()
979
+ status = str(payload.get("status") or "unknown")
980
+ published_port = payload.get("published_port")
981
+ startup_port = payload.get("startup_port")
982
+ rows: list[tuple[str, str, str | None]] = [
983
+ ("url", str(payload.get("public_url") or "—"), "cyan"),
984
+ ("state", f"{_computer_status_dot(status)} {status}", _computer_status_style(status)),
985
+ ]
986
+ if published_port:
987
+ rows.append(("port", str(published_port), "bold white"))
988
+ elif startup_port:
989
+ rows.append(("port", str(startup_port), "bold white"))
990
+ rows.append(("source", "durable startup", "dim"))
991
+ else:
992
+ rows.append(("port", "—", "dim"))
993
+ if published_port and startup_port and startup_port != published_port:
994
+ rows.append(("startup", str(startup_port), "dim"))
995
+ for label, value, style in rows:
996
+ body.append(f"{label:<8}", style="dim")
997
+ body.append(value, style=style or "white")
998
+ body.append("\n")
999
+ _UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
1000
+
1001
+
735
1002
  def _render_whoami_panel(payload: dict[str, Any]) -> None:
736
1003
  mods = _UI.rich()
737
1004
  Panel, Text = mods["Panel"], mods["Text"]
@@ -939,7 +1206,7 @@ def handle_create(
939
1206
  computer_id = result.get("id") or result.get("slug")
940
1207
  if not computer_id:
941
1208
  return code
942
- return _enter_computer(config, str(computer_id), announce_created=False)
1209
+ return _enter_computer(config, str(computer_id))
943
1210
 
944
1211
 
945
1212
  def handle_list(config: RuntimeConfig) -> int:
@@ -973,6 +1240,26 @@ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
973
1240
  return report_success(result, lambda p: _render_computer_panel(p, title="computer"))
974
1241
 
975
1242
 
1243
+ def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
1244
+ if (err := _require_api_key(config)) is not None:
1245
+ return err
1246
+
1247
+ if computer_id is None:
1248
+ if not _interactive():
1249
+ return report_error("computer id is required")
1250
+ picked = _pick_computer(config, "Pick a computer")
1251
+ if picked is None:
1252
+ return 0
1253
+ computer_id = picked.get("id") or picked.get("slug")
1254
+ if not computer_id:
1255
+ return report_error("computer is missing an id")
1256
+
1257
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1258
+ computer_id = _resolve_computer_ref(client, computer_id)
1259
+ result = client.get_computer_network(computer_id)
1260
+ return report_success(result, lambda p: _render_network_panel(p, title="url"))
1261
+
1262
+
976
1263
  def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
977
1264
  if (err := _require_api_key(config)) is not None:
978
1265
  return err
@@ -1139,40 +1426,233 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
1139
1426
 
1140
1427
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1141
1428
  computer_id = _resolve_computer_ref(client, computer_id)
1142
- _with_spinner(
1429
+ result = _with_spinner(
1143
1430
  f"publishing port {port} on {computer_id}…",
1144
1431
  lambda: client.publish_port(computer_id, port),
1145
1432
  )
1146
1433
 
1147
- def render(_: dict[str, Any]) -> None:
1434
+ def render(payload: dict[str, Any]) -> None:
1148
1435
  _UI.console().print(
1149
- f"[green]✓[/green] public url for [bold]{computer_id}[/bold] pinned to [bold]{port}[/bold]"
1436
+ f"[green]✓[/green] public url for [bold]{computer_id}[/bold] pinned to [bold]{payload.get('published_port') or port}[/bold]"
1150
1437
  )
1438
+ url = payload.get("public_url")
1439
+ if url:
1440
+ _UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
1151
1441
 
1152
- return report_success(
1153
- {
1154
- "message": f"computer {computer_id} published port {port}",
1155
- "computer_id": computer_id,
1156
- "port": port,
1157
- },
1158
- render,
1159
- )
1442
+ return report_success(result, render)
1443
+
1444
+
1445
+ def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: list[str] | None) -> int:
1446
+ if (err := _require_api_key(config)) is not None:
1447
+ return err
1448
+ if not computer_id:
1449
+ return report_error("computer id is required")
1450
+ if not specs:
1451
+ return report_error("at least one port mapping is required")
1452
+
1453
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1454
+ resolved_id = _resolve_computer_ref(client, computer_id)
1455
+ existing_records = _load_proxy_records(clean_stale=True)
1456
+ existing_ports = {int(r.get("local_port") or 0) for r in existing_records}
1457
+ mappings: list[tuple[int, int]] = []
1458
+ seen_ports: set[int] = set()
1459
+ for spec in specs:
1460
+ local_port, remote_port = _parse_proxy_port_spec(spec)
1461
+ if local_port in existing_ports:
1462
+ return report_error(f"local port {local_port} is already in use by a runtime proxy")
1463
+ if local_port in seen_ports:
1464
+ return report_error(f"local port {local_port} was specified more than once")
1465
+ seen_ports.add(local_port)
1466
+ mappings.append((local_port, remote_port))
1467
+
1468
+ started: list[dict[str, Any]] = []
1469
+ started_ports: list[int] = []
1470
+ for local_port, remote_port in mappings:
1471
+ try:
1472
+ _spawn_proxy_agent(config.base_url, str(config.api_key or ""), resolved_id, computer_id, local_port, remote_port)
1473
+ except RuntimeAPIError as exc:
1474
+ if started_ports:
1475
+ _stop_proxy_ports(started_ports, stop_all=False)
1476
+ return report_error(str(exc), status_code=exc.status_code)
1477
+ started_ports.append(local_port)
1478
+ started.append({"computer_id": resolved_id, "computer": computer_id, "local_port": local_port, "remote_port": remote_port})
1479
+
1480
+ def render(payload: dict[str, Any]) -> None:
1481
+ _UI.console().print("[green]✓[/green] proxy started")
1482
+ for proxy in payload.get("proxies", []):
1483
+ _UI.console().print(
1484
+ f" [bold]localhost:{proxy['local_port']}[/bold] -> [cyan]{proxy['computer']}[/cyan]:{proxy['remote_port']}"
1485
+ )
1486
+
1487
+ return report_success({"proxies": started}, render)
1488
+
1489
+
1490
+ def handle_proxy_ls(config: RuntimeConfig) -> int:
1491
+ proxies = _load_proxy_records(clean_stale=True)
1492
+
1493
+ def render(payload: dict[str, Any]) -> None:
1494
+ mods = _UI.rich()
1495
+ Table = mods["Table"]
1496
+ table = Table(title="proxies")
1497
+ table.add_column("VM", style="white")
1498
+ table.add_column("Local", style="cyan")
1499
+ table.add_column("Remote", style="white")
1500
+ table.add_column("Status", style="green")
1501
+ for proxy in payload.get("proxies", []):
1502
+ table.add_row(
1503
+ str(proxy.get("display_name") or proxy.get("computer_id") or "—"),
1504
+ str(proxy.get("local_port") or "—"),
1505
+ str(proxy.get("remote_port") or "—"),
1506
+ str(proxy.get("status") or "unknown"),
1507
+ )
1508
+ _UI.console().print(table)
1509
+
1510
+ return report_success({"proxies": proxies}, render if _interactive() else None)
1511
+
1512
+
1513
+ def handle_proxy_stop(config: RuntimeConfig, local_ports: list[int] | None, *, stop_all: bool = False) -> int:
1514
+ if not stop_all and not local_ports:
1515
+ return report_error("pass one or more local ports, or use --all")
1516
+
1517
+ stopped = _stop_proxy_ports(local_ports or [], stop_all=stop_all)
1518
+
1519
+ def render(payload: dict[str, Any]) -> None:
1520
+ _UI.console().print("[green]✓[/green] proxy stopped")
1521
+ for proxy in payload.get("proxies", []):
1522
+ _UI.console().print(
1523
+ f" [bold]localhost:{proxy.get('local_port')}[/bold] -> [cyan]{proxy.get('display_name') or proxy.get('computer_id')}[/cyan]:{proxy.get('remote_port')}"
1524
+ )
1525
+
1526
+ return report_success({"proxies": stopped}, render if _interactive() else None)
1527
+
1528
+
1529
+ def run_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
1530
+ if not api_key:
1531
+ print("proxy agent missing api key", file=sys.stderr)
1532
+ return 1
1533
+
1534
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1535
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1536
+ try:
1537
+ listener.bind(("127.0.0.1", local_port))
1538
+ listener.listen()
1539
+ listener.settimeout(1.0)
1540
+ except OSError as exc:
1541
+ print(f"proxy listen failed on localhost:{local_port}: {exc}", file=sys.stderr)
1542
+ return 1
1543
+
1544
+ proxy_dir = _proxy_dir()
1545
+ proxy_dir.mkdir(parents=True, exist_ok=True)
1546
+ state = {
1547
+ "pid": os.getpid(),
1548
+ "computer_id": computer_id,
1549
+ "display_name": display_name,
1550
+ "local_port": local_port,
1551
+ "remote_port": remote_port,
1552
+ "started_at": datetime.now(timezone.utc).isoformat(),
1553
+ }
1554
+ _proxy_state_path(local_port).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
1555
+
1556
+ stop_event = threading.Event()
1557
+
1558
+ def handle_signal(_signum: int, _frame: Any) -> None:
1559
+ stop_event.set()
1560
+ with contextlib.suppress(OSError):
1561
+ listener.close()
1160
1562
 
1563
+ signal.signal(signal.SIGTERM, handle_signal)
1564
+ signal.signal(signal.SIGINT, handle_signal)
1565
+
1566
+ try:
1567
+ while not stop_event.is_set():
1568
+ try:
1569
+ conn, _ = listener.accept()
1570
+ except socket.timeout:
1571
+ continue
1572
+ except OSError:
1573
+ if stop_event.is_set():
1574
+ break
1575
+ continue
1576
+ thread = threading.Thread(
1577
+ target=_handle_proxy_client,
1578
+ args=(base_url, api_key, computer_id, remote_port, conn),
1579
+ daemon=True,
1580
+ )
1581
+ thread.start()
1582
+ finally:
1583
+ with contextlib.suppress(OSError):
1584
+ listener.close()
1585
+ _remove_proxy_state(local_port)
1586
+ return 0
1161
1587
 
1162
- def _enter_computer(config: RuntimeConfig, computer_id: str, *, announce_created: bool = True) -> int:
1588
+
1589
+ def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_port: int, conn: socket.socket) -> None:
1590
+ client = RuntimeClient(base_url=base_url, api_key=api_key)
1591
+ try:
1592
+ ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
1593
+ ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
1594
+ if not ws_url:
1595
+ raise RuntimeAPIError("proxy endpoint did not return a websocket url")
1596
+ with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None) as ws:
1597
+ stop = threading.Event()
1598
+
1599
+ def local_to_ws() -> None:
1600
+ try:
1601
+ while not stop.is_set():
1602
+ data = conn.recv(65536)
1603
+ if not data:
1604
+ break
1605
+ ws.send(data)
1606
+ except Exception:
1607
+ pass
1608
+ finally:
1609
+ stop.set()
1610
+ with contextlib.suppress(Exception):
1611
+ ws.close()
1612
+
1613
+ def ws_to_local() -> None:
1614
+ try:
1615
+ while not stop.is_set():
1616
+ data = ws.recv()
1617
+ if data is None:
1618
+ break
1619
+ if isinstance(data, str):
1620
+ chunk = data.encode()
1621
+ else:
1622
+ chunk = data
1623
+ if chunk:
1624
+ conn.sendall(chunk)
1625
+ except Exception:
1626
+ pass
1627
+ finally:
1628
+ stop.set()
1629
+ with contextlib.suppress(OSError):
1630
+ conn.shutdown(socket.SHUT_RDWR)
1631
+
1632
+ t1 = threading.Thread(target=local_to_ws, daemon=True)
1633
+ t2 = threading.Thread(target=ws_to_local, daemon=True)
1634
+ t1.start()
1635
+ t2.start()
1636
+ t1.join()
1637
+ t2.join()
1638
+ except Exception as exc:
1639
+ print(f"proxy connection failed for localhost:{conn.getsockname()[1]} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
1640
+ finally:
1641
+ with contextlib.suppress(OSError):
1642
+ conn.close()
1643
+
1644
+
1645
+ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
1163
1646
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1164
1647
  ticket_payload = _with_spinner(
1165
1648
  f"preparing console for {computer_id}…",
1166
1649
  lambda: client.create_terminal_ticket(computer_id),
1167
1650
  )
1168
- ws_url = _resolve_terminal_ws_url(config.base_url, ticket_payload.get("ws_url"))
1651
+ ws_url = _resolve_ws_url(config.base_url, ticket_payload.get("ws_url"))
1169
1652
  if not ws_url:
1170
1653
  return report_error("terminal endpoint did not return a websocket url")
1171
1654
 
1172
- if announce_created:
1173
- _UI.console().print("[cyan]●[/cyan] Connecting to console...")
1174
- else:
1175
- _UI.console().print("[cyan]●[/cyan] Connecting to console...")
1655
+ _UI.console().print("[cyan]●[/cyan] Connecting to console...")
1176
1656
 
1177
1657
  try:
1178
1658
  return _run_terminal_session(ws_url)
@@ -1182,21 +1662,6 @@ def _enter_computer(config: RuntimeConfig, computer_id: str, *, announce_created
1182
1662
  return report_error(f"terminal failed: {exc}")
1183
1663
 
1184
1664
 
1185
- def _resolve_terminal_ws_url(base_url: str, ws_url: Any) -> str:
1186
- text = str(ws_url or "").strip()
1187
- if not text:
1188
- return ""
1189
- if text.startswith("ws://") or text.startswith("wss://"):
1190
- return text
1191
-
1192
- parsed = urlparse(base_url)
1193
- scheme = "wss" if parsed.scheme == "https" else "ws"
1194
- root = f"{scheme}://{parsed.netloc}"
1195
- if text.startswith("/"):
1196
- return root + text
1197
- return urljoin(root + "/", text)
1198
-
1199
-
1200
1665
  def _run_terminal_session(ws_url: str) -> int:
1201
1666
  if not sys.stdin.isatty() or not sys.stdout.isatty():
1202
1667
  return report_error("enter requires an interactive terminal")
@@ -1241,6 +1706,9 @@ def _run_terminal_session(ws_url: str) -> int:
1241
1706
  stop.set()
1242
1707
  return
1243
1708
  except ConnectionClosed:
1709
+ if not stop.is_set() and not result.get("error") and int(result.get("code") or 0) == 0:
1710
+ result["code"] = 1
1711
+ result["error"] = "terminal connection closed unexpectedly"
1244
1712
  stop.set()
1245
1713
  except Exception as exc: # pragma: no cover - defensive for real TTY sessions
1246
1714
  result["code"] = 1
@@ -79,6 +79,9 @@ class RuntimeClient:
79
79
  def get_computer(self, computer_id: str) -> dict[str, Any]:
80
80
  return self._request("GET", f"/api/computers/{computer_id}", auth_required=True)
81
81
 
82
+ def get_computer_network(self, computer_id: str) -> dict[str, Any]:
83
+ return self._request("GET", f"/api/computers/{computer_id}/network", auth_required=True)
84
+
82
85
  def start_computer(self, computer_id: str) -> dict[str, Any]:
83
86
  return self._request(
84
87
  "POST",
@@ -98,6 +101,17 @@ class RuntimeClient:
98
101
  timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
99
102
  )
100
103
 
104
+ def create_proxy_ticket(self, computer_id: str, port: int) -> dict[str, Any]:
105
+ if port <= 0 or port > 65535:
106
+ raise RuntimeAPIError("port must be between 1 and 65535")
107
+ return self._request(
108
+ "POST",
109
+ f"/api/computers/{computer_id}/proxy-ticket",
110
+ json={"port": port},
111
+ auth_required=True,
112
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
113
+ )
114
+
101
115
  def run_command(
102
116
  self,
103
117
  computer_id: str,
@@ -122,17 +136,12 @@ class RuntimeClient:
122
136
  def publish_port(self, computer_id: str, port: int) -> dict[str, Any]:
123
137
  if port <= 0 or port > 65535:
124
138
  raise RuntimeAPIError("port must be between 1 and 65535")
125
-
126
- result = self.run_command(
127
- computer_id,
128
- f"install -d -m 0755 /run/runtime && printf '%s\\n' '{port}' > /run/runtime/published-port",
129
- uid=0,
130
- gid=0,
139
+ return self._request(
140
+ "POST",
141
+ f"/api/computers/{computer_id}/publish",
142
+ json={"port": port},
143
+ auth_required=True,
131
144
  )
132
- if result.get("exit_code") != 0:
133
- message = result.get("stderr") or result.get("stdout") or "failed to publish port"
134
- raise RuntimeAPIError(str(message).strip())
135
- return result
136
145
 
137
146
  def _request(
138
147
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.8
3
+ Version: 0.4.10
4
4
  Summary: Runtime Python SDK and CLI
5
5
  Project-URL: Repository, https://github.com/The-Money-Company-Limited/runtimevm
6
6
  Project-URL: Issues, https://github.com/The-Money-Company-Limited/runtimevm/issues
File without changes
File without changes