runtime-sdk 0.4.9__tar.gz → 0.4.11__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.9
3
+ Version: 0.4.11
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
@@ -54,16 +54,27 @@ runtime logout
54
54
  runtime login --api-key rt_live_...
55
55
 
56
56
  # Computers
57
- runtime create # in a real TTY, creates then drops you into the console
57
+ runtime create # creates a computer with the starter app already published
58
58
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
59
59
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
60
60
  runtime list
61
61
  runtime info <id>
62
62
  runtime start <id>
63
- runtime run <id> "echo hello"
63
+ runtime run <id> "echo hello" # one-shot foreground command only
64
64
  runtime run <id> "apt install -y nodejs" --uid 0
65
- runtime publish <id> 3000
65
+ runtime startup show <id>
66
+ runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
67
+ runtime startup clear <id> # low-level durable service config
68
+ runtime service show <id> # user-facing alias for the durable published app
69
+ runtime service clear <id>
70
+ runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
66
71
  runtime delete <id>
72
+
73
+ # Inside a running computer, the helper installed by Runtime can manage the
74
+ # durable app without leaving the sandbox:
75
+ # runtime-env publish 3000
76
+ # runtime-env service show
77
+ # runtime-env service clear
67
78
  ```
68
79
 
69
80
  ## Python
@@ -73,12 +84,12 @@ from runtime_sdk import RuntimeClient
73
84
 
74
85
  client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
75
86
 
76
- # Create a computer
87
+ # Create a computer. New computers start with the starter app already published.
77
88
  computer = client.create_computer()
78
89
  print(computer["public_url"]) # https://goldbird.runruntime.dev
79
90
 
80
- # Or create one with a durable startup command.
81
- # That startup config is replayed after cold restore / auto-wake.
91
+ # Or create one with an explicit durable app command.
92
+ # That saved service is replayed after cold restore / start.
82
93
  app = client.create_computer(
83
94
  slug="myapp",
84
95
  command="python3 app.py",
@@ -93,7 +104,8 @@ print(result["stdout"])
93
104
  # Wake a cold computer explicitly
94
105
  client.start_computer(app["id"])
95
106
 
96
- # Pin the public URL to a local app port
107
+ # Promote the running app on a local port to the durable public app.
108
+ # Runtime inspects the listening process and saves its command + cwd when possible.
97
109
  client.publish_port(computer["id"], 3000)
98
110
 
99
111
  # List, info, delete
@@ -120,10 +132,16 @@ make smoke
120
132
  Use `make deploy` instead of `make sync` when migrations, env
121
133
  files, Caddy, or systemd units changed.
122
134
 
123
- Cold restore and public auto-wake replay the saved startup command only for
124
- computers created with `--command` + `--port` (or the SDK `command`/`port`
125
- arguments). If you start an app later via a one-off `runtime run`, the
126
- filesystem is restored after going cold, but that ad-hoc process is not.
135
+ Cold restore and explicit `runtime start` replay the saved published app command.
136
+ New computers seed that durable app from the starter workspace. Later,
137
+ `runtime publish <id> <port>` can promote a running listener into the saved
138
+ public app definition by inspecting the live process. Inside a running computer,
139
+ `runtime-env publish <port>` does the same thing using a computer-scoped token
140
+ installed by Runtime. `runtime service show|clear` and `runtime-env service
141
+ show|clear` expose that same durable app state directly. The low-level
142
+ `runtime startup ...` commands still map to the same durable state. A one-off
143
+ `runtime run` stays one-shot: the filesystem is restored after going cold, but
144
+ that ad-hoc process is not.
127
145
 
128
146
  Run the SDK unit tests through the backend project environment:
129
147
 
@@ -35,16 +35,27 @@ runtime logout
35
35
  runtime login --api-key rt_live_...
36
36
 
37
37
  # Computers
38
- runtime create # in a real TTY, creates then drops you into the console
38
+ runtime create # creates a computer with the starter app already published
39
39
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
40
40
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
41
41
  runtime list
42
42
  runtime info <id>
43
43
  runtime start <id>
44
- runtime run <id> "echo hello"
44
+ runtime run <id> "echo hello" # one-shot foreground command only
45
45
  runtime run <id> "apt install -y nodejs" --uid 0
46
- runtime publish <id> 3000
46
+ runtime startup show <id>
47
+ runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
48
+ runtime startup clear <id> # low-level durable service config
49
+ runtime service show <id> # user-facing alias for the durable published app
50
+ runtime service clear <id>
51
+ runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
47
52
  runtime delete <id>
53
+
54
+ # Inside a running computer, the helper installed by Runtime can manage the
55
+ # durable app without leaving the sandbox:
56
+ # runtime-env publish 3000
57
+ # runtime-env service show
58
+ # runtime-env service clear
48
59
  ```
49
60
 
50
61
  ## Python
@@ -54,12 +65,12 @@ from runtime_sdk import RuntimeClient
54
65
 
55
66
  client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
56
67
 
57
- # Create a computer
68
+ # Create a computer. New computers start with the starter app already published.
58
69
  computer = client.create_computer()
59
70
  print(computer["public_url"]) # https://goldbird.runruntime.dev
60
71
 
61
- # Or create one with a durable startup command.
62
- # That startup config is replayed after cold restore / auto-wake.
72
+ # Or create one with an explicit durable app command.
73
+ # That saved service is replayed after cold restore / start.
63
74
  app = client.create_computer(
64
75
  slug="myapp",
65
76
  command="python3 app.py",
@@ -74,7 +85,8 @@ print(result["stdout"])
74
85
  # Wake a cold computer explicitly
75
86
  client.start_computer(app["id"])
76
87
 
77
- # Pin the public URL to a local app port
88
+ # Promote the running app on a local port to the durable public app.
89
+ # Runtime inspects the listening process and saves its command + cwd when possible.
78
90
  client.publish_port(computer["id"], 3000)
79
91
 
80
92
  # List, info, delete
@@ -101,10 +113,16 @@ make smoke
101
113
  Use `make deploy` instead of `make sync` when migrations, env
102
114
  files, Caddy, or systemd units changed.
103
115
 
104
- Cold restore and public auto-wake replay the saved startup command only for
105
- computers created with `--command` + `--port` (or the SDK `command`/`port`
106
- arguments). If you start an app later via a one-off `runtime run`, the
107
- filesystem is restored after going cold, but that ad-hoc process is not.
116
+ Cold restore and explicit `runtime start` replay the saved published app command.
117
+ New computers seed that durable app from the starter workspace. Later,
118
+ `runtime publish <id> <port>` can promote a running listener into the saved
119
+ public app definition by inspecting the live process. Inside a running computer,
120
+ `runtime-env publish <port>` does the same thing using a computer-scoped token
121
+ installed by Runtime. `runtime service show|clear` and `runtime-env service
122
+ show|clear` expose that same durable app state directly. The low-level
123
+ `runtime startup ...` commands still map to the same durable state. A one-off
124
+ `runtime run` stays one-shot: the filesystem is restored after going cold, but
125
+ that ad-hoc process is not.
108
126
 
109
127
  Run the SDK unit tests through the backend project environment:
110
128
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.9"
3
+ version = "0.4.11"
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
 
@@ -159,16 +165,16 @@ def build_parser() -> argparse.ArgumentParser:
159
165
  subparsers.add_parser("whoami")
160
166
  subparsers.add_parser("logout")
161
167
 
162
- create_cmd = subparsers.add_parser("create", help="Create a new computer")
168
+ create_cmd = subparsers.add_parser("create", help="Create a new computer with the starter app published by default")
163
169
  create_cmd.add_argument(
164
170
  "name",
165
171
  nargs="?",
166
172
  default=None,
167
173
  help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
168
174
  )
169
- create_cmd.add_argument("--command", dest="startup_command", help="App startup command to run after create")
170
- create_cmd.add_argument("--cwd", help="Working directory for the startup command")
171
- create_cmd.add_argument("--port", type=int, help="App port to publish after startup")
175
+ create_cmd.add_argument("--command", dest="startup_command", help="Durable startup command to save on create")
176
+ create_cmd.add_argument("--cwd", help="Working directory for the durable startup command")
177
+ create_cmd.add_argument("--port", type=int, help="App port to publish for durable startup")
172
178
  subparsers.add_parser("list", aliases=["ls"], help="List computers")
173
179
 
174
180
  enter_cmd = subparsers.add_parser("enter", help="Enter a computer's console")
@@ -177,13 +183,16 @@ 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
 
183
192
  delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
184
193
  delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
185
194
 
186
- run_cmd = subparsers.add_parser("run", help="Run a command in a computer")
195
+ run_cmd = subparsers.add_parser("run", help="Run a one-shot command in a computer")
187
196
  run_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
188
197
  run_cmd.add_argument(
189
198
  "run_command",
@@ -198,11 +207,47 @@ def build_parser() -> argparse.ArgumentParser:
198
207
  run_cmd.add_argument("--shell", help="Shell to use")
199
208
 
200
209
  publish_cmd = subparsers.add_parser(
201
- "publish", help="Pin the public URL to a local app port"
210
+ "publish", help="Promote the running app on a local port into the durable public app"
202
211
  )
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
+ startup_cmd = subparsers.add_parser("startup", help="Manage durable startup config")
216
+ startup_subparsers = startup_cmd.add_subparsers(dest="startup_action", required=True)
217
+ startup_show = startup_subparsers.add_parser("show", help="Show durable startup config")
218
+ startup_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
219
+ startup_set = startup_subparsers.add_parser("set", help="Set durable startup config")
220
+ startup_set.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
221
+ startup_set.add_argument("--command", dest="startup_value_command", required=True, help="Durable startup command")
222
+ startup_set.add_argument("--cwd", help="Working directory for the durable startup command")
223
+ startup_set.add_argument("--port", required=True, type=int, help="App port to publish for durable startup")
224
+ startup_clear = startup_subparsers.add_parser("clear", help="Clear durable startup config")
225
+ startup_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
226
+
227
+ service_cmd = subparsers.add_parser("service", help="Manage the durable published app service")
228
+ service_subparsers = service_cmd.add_subparsers(dest="service_action", required=True)
229
+ service_show = service_subparsers.add_parser("show", help="Show the durable published app service")
230
+ service_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
231
+ service_clear = service_subparsers.add_parser("clear", help="Clear the durable published app service")
232
+ service_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
233
+
234
+ proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
235
+ proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
236
+ proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
237
+ proxy_start.add_argument("id", help="Computer ID (hostname)")
238
+ proxy_start.add_argument("ports", nargs="+", help="Port mappings like 5432 or 15432:5432")
239
+ proxy_subparsers.add_parser("ls", help="List background local port proxies")
240
+ proxy_stop = proxy_subparsers.add_parser("stop", help="Stop one or more background local port proxies")
241
+ proxy_stop.add_argument("local_ports", nargs="*", type=int, help="Local ports to stop")
242
+ proxy_stop.add_argument("--all", action="store_true", help="Stop all running proxies")
243
+
244
+ proxy_agent = subparsers.add_parser("_proxy_agent", help=argparse.SUPPRESS)
245
+ proxy_agent.add_argument("--base-url", required=True)
246
+ proxy_agent.add_argument("--computer-id", required=True)
247
+ proxy_agent.add_argument("--display-name", required=True)
248
+ proxy_agent.add_argument("--local-port", type=int, required=True)
249
+ proxy_agent.add_argument("--remote-port", type=int, required=True)
250
+
206
251
  return parser
207
252
 
208
253
 
@@ -247,6 +292,8 @@ def main(argv: list[str] | None = None) -> int:
247
292
  return handle_list(config)
248
293
  if args.command == "info":
249
294
  return handle_info(config, args.id)
295
+ if args.command == "url":
296
+ return handle_url(config, args.id)
250
297
  if args.command == "start":
251
298
  return handle_start(config, args.id)
252
299
  if args.command == "enter":
@@ -259,6 +306,37 @@ def main(argv: list[str] | None = None) -> int:
259
306
  )
260
307
  if args.command == "publish":
261
308
  return handle_publish(config, args.id, args.port)
309
+ if args.command == "startup":
310
+ if args.startup_action == "show":
311
+ return handle_startup_show(config, args.id)
312
+ if args.startup_action == "set":
313
+ return handle_startup_set(config, args.id, args.startup_value_command, args.cwd, args.port)
314
+ if args.startup_action == "clear":
315
+ return handle_startup_clear(config, args.id)
316
+ return report_error("unknown startup command")
317
+ if args.command == "service":
318
+ if args.service_action == "show":
319
+ return handle_service_show(config, args.id)
320
+ if args.service_action == "clear":
321
+ return handle_service_clear(config, args.id)
322
+ return report_error("unknown service command")
323
+ if args.command == "proxy":
324
+ if args.proxy_command == "start":
325
+ return handle_proxy_start(config, args.id, args.ports)
326
+ if args.proxy_command == "ls":
327
+ return handle_proxy_ls(config)
328
+ if args.proxy_command == "stop":
329
+ return handle_proxy_stop(config, args.local_ports, stop_all=args.all)
330
+ return report_error("unknown proxy command")
331
+ if args.command == "_proxy_agent":
332
+ return run_proxy_agent(
333
+ args.base_url,
334
+ os.environ.get("RUNTIME_PROXY_API_KEY", ""),
335
+ args.computer_id,
336
+ args.display_name,
337
+ args.local_port,
338
+ args.remote_port,
339
+ )
262
340
  except RuntimeAPIError as exc:
263
341
  return report_error(str(exc), status_code=exc.status_code)
264
342
  except RuntimeConfigError as exc:
@@ -311,6 +389,201 @@ def _require_api_key(config: RuntimeConfig) -> int | None:
311
389
  return report_error("missing api key; run login or verify first")
312
390
 
313
391
 
392
+ def _runtime_state_dir() -> Path:
393
+ root = os.environ.get("XDG_STATE_HOME")
394
+ if root:
395
+ return Path(root) / "runtime"
396
+ return Path.home() / ".local" / "state" / "runtime"
397
+
398
+
399
+ def _proxy_dir() -> Path:
400
+ return _runtime_state_dir() / "proxies"
401
+
402
+
403
+ def _proxy_state_path(local_port: int) -> Path:
404
+ return _proxy_dir() / f"proxy-{local_port}.json"
405
+
406
+
407
+ def _proxy_log_path(local_port: int) -> Path:
408
+ return _proxy_dir() / f"proxy-{local_port}.log"
409
+
410
+
411
+ def _pid_running(pid: int) -> bool:
412
+ if pid <= 0:
413
+ return False
414
+ try:
415
+ os.kill(pid, 0)
416
+ return True
417
+ except OSError:
418
+ return False
419
+
420
+
421
+ def _pid_command(pid: int) -> str:
422
+ if pid <= 0:
423
+ return ""
424
+ try:
425
+ return subprocess.check_output(
426
+ ["ps", "-o", "command=", "-p", str(pid)],
427
+ text=True,
428
+ stderr=subprocess.DEVNULL,
429
+ ).strip()
430
+ except (OSError, subprocess.SubprocessError):
431
+ return ""
432
+
433
+
434
+ def _proxy_pid_matches(pid: int, local_port: int) -> bool:
435
+ if not _pid_running(pid):
436
+ return False
437
+ command = _pid_command(pid)
438
+ if not command:
439
+ return False
440
+ with contextlib.suppress(ValueError):
441
+ argv = shlex.split(command)
442
+ if "_proxy_agent" not in argv:
443
+ return False
444
+ if "--local-port" not in argv:
445
+ return False
446
+ index = argv.index("--local-port")
447
+ if index + 1 >= len(argv) or argv[index + 1] != str(local_port):
448
+ return False
449
+ return "runtime_sdk.cli" in argv or any(part.endswith("runtime_sdk.cli") for part in argv)
450
+ return False
451
+
452
+
453
+ def _load_proxy_records(*, clean_stale: bool = False) -> list[dict[str, Any]]:
454
+ records: list[dict[str, Any]] = []
455
+ directory = _proxy_dir()
456
+ if not directory.exists():
457
+ return records
458
+ for path in sorted(directory.glob("proxy-*.json")):
459
+ try:
460
+ record = json.loads(path.read_text())
461
+ except (OSError, ValueError):
462
+ continue
463
+ if not isinstance(record, dict):
464
+ continue
465
+ record["state_path"] = str(path)
466
+ pid = int(record.get("pid") or 0)
467
+ local_port = int(record.get("local_port") or 0)
468
+ record["status"] = "running" if _proxy_pid_matches(pid, local_port) else "stopped"
469
+ if clean_stale and record["status"] != "running":
470
+ with contextlib.suppress(OSError):
471
+ path.unlink()
472
+ continue
473
+ records.append(record)
474
+ return records
475
+
476
+
477
+ def _parse_proxy_port_spec(spec: str) -> tuple[int, int]:
478
+ text = str(spec or "").strip()
479
+ if not text:
480
+ raise RuntimeAPIError("port mapping is required")
481
+ if ":" in text:
482
+ local_text, remote_text = text.split(":", 1)
483
+ else:
484
+ local_text, remote_text = text, text
485
+ try:
486
+ local_port = int(local_text)
487
+ remote_port = int(remote_text)
488
+ except ValueError as exc:
489
+ raise RuntimeAPIError(f"invalid port mapping: {text}") from exc
490
+ for port in (local_port, remote_port):
491
+ if port <= 0 or port > 65535:
492
+ raise RuntimeAPIError("port must be between 1 and 65535")
493
+ return local_port, remote_port
494
+
495
+
496
+ def _resolve_ws_url(base_url: str, ws_url: Any) -> str:
497
+ text = str(ws_url or "").strip()
498
+ if not text:
499
+ return ""
500
+ if text.startswith("ws://") or text.startswith("wss://"):
501
+ return text
502
+
503
+ parsed = urlparse(base_url)
504
+ scheme = "wss" if parsed.scheme == "https" else "ws"
505
+ root = f"{scheme}://{parsed.netloc}"
506
+ if text.startswith("/"):
507
+ return root + text
508
+ return urljoin(root + "/", text)
509
+
510
+
511
+ def _spawn_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
512
+ proxy_dir = _proxy_dir()
513
+ proxy_dir.mkdir(parents=True, exist_ok=True)
514
+ log_path = _proxy_log_path(local_port)
515
+ log_file = log_path.open("a", encoding="utf-8")
516
+ child_env = os.environ.copy()
517
+ child_env["RUNTIME_PROXY_API_KEY"] = api_key
518
+ try:
519
+ proc = subprocess.Popen(
520
+ [
521
+ sys.executable,
522
+ "-m",
523
+ "runtime_sdk.cli",
524
+ "_proxy_agent",
525
+ "--base-url",
526
+ base_url,
527
+ "--computer-id",
528
+ computer_id,
529
+ "--display-name",
530
+ display_name,
531
+ "--local-port",
532
+ str(local_port),
533
+ "--remote-port",
534
+ str(remote_port),
535
+ ],
536
+ stdin=subprocess.DEVNULL,
537
+ stdout=log_file,
538
+ stderr=log_file,
539
+ start_new_session=True,
540
+ close_fds=True,
541
+ env=child_env,
542
+ )
543
+ finally:
544
+ log_file.close()
545
+
546
+ deadline = time.time() + 4.0
547
+ state_path = _proxy_state_path(local_port)
548
+ while time.time() < deadline:
549
+ if state_path.exists():
550
+ return proc.pid
551
+ code = proc.poll()
552
+ if code is not None:
553
+ break
554
+ time.sleep(0.1)
555
+
556
+ message = f"proxy failed to start on localhost:{local_port}"
557
+ with contextlib.suppress(OSError):
558
+ tail = log_path.read_text(encoding="utf-8").strip()
559
+ if tail:
560
+ message = tail.splitlines()[-1]
561
+ raise RuntimeAPIError(message)
562
+
563
+
564
+ def _remove_proxy_state(local_port: int) -> None:
565
+ with contextlib.suppress(OSError):
566
+ _proxy_state_path(local_port).unlink()
567
+
568
+
569
+ def _stop_proxy_ports(local_ports: list[int], *, stop_all: bool = False) -> list[dict[str, Any]]:
570
+ records = _load_proxy_records(clean_stale=False)
571
+ stopped: list[dict[str, Any]] = []
572
+ wanted = set(local_ports)
573
+ for record in records:
574
+ port = int(record.get("local_port") or 0)
575
+ if not stop_all and port not in wanted:
576
+ continue
577
+ pid = int(record.get("pid") or 0)
578
+ if _proxy_pid_matches(pid, port):
579
+ with contextlib.suppress(OSError):
580
+ os.kill(pid, signal.SIGTERM)
581
+ _remove_proxy_state(port)
582
+ record["status"] = "stopped"
583
+ stopped.append(record)
584
+ return stopped
585
+
586
+
314
587
  def _prompt_text(message: str, *, default: str | None = None) -> str | None:
315
588
  """Ask for a line of text. Returns None if the user left it blank.
316
589
 
@@ -424,7 +697,7 @@ def _resolve_computer_ref(client: RuntimeClient, ref: str) -> str:
424
697
  return resolved or ref
425
698
  if len(matches) > 1:
426
699
  raise RuntimeAPIError(f"multiple computers matched {ref!r}; use the exact computer id")
427
- return ref
700
+ raise RuntimeAPIError(f"computer {ref!r} not found")
428
701
 
429
702
 
430
703
 
@@ -732,6 +1005,54 @@ def _render_run_result(result: dict[str, Any]) -> None:
732
1005
  console.print(f"[{color}]exit {exit_code}[/{color}]")
733
1006
 
734
1007
 
1008
+ def _render_network_panel(payload: dict[str, Any], *, title: str = "url") -> None:
1009
+ mods = _UI.rich()
1010
+ Panel, Text = mods["Panel"], mods["Text"]
1011
+ body = Text()
1012
+ status = str(payload.get("status") or "unknown")
1013
+ published_port = payload.get("published_port")
1014
+ startup_port = payload.get("startup_port")
1015
+ rows: list[tuple[str, str, str | None]] = [
1016
+ ("url", str(payload.get("public_url") or "—"), "cyan"),
1017
+ ("state", f"{_computer_status_dot(status)} {status}", _computer_status_style(status)),
1018
+ ]
1019
+ if published_port:
1020
+ rows.append(("port", str(published_port), "bold white"))
1021
+ elif startup_port:
1022
+ rows.append(("port", str(startup_port), "bold white"))
1023
+ rows.append(("source", "durable startup", "dim"))
1024
+ else:
1025
+ rows.append(("port", "—", "dim"))
1026
+ if published_port and startup_port and startup_port != published_port:
1027
+ rows.append(("startup", str(startup_port), "dim"))
1028
+ for label, value, style in rows:
1029
+ body.append(f"{label:<8}", style="dim")
1030
+ body.append(value, style=style or "white")
1031
+ body.append("\n")
1032
+ _UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
1033
+
1034
+
1035
+ def _render_startup_panel(payload: dict[str, Any], *, title: str = "startup") -> None:
1036
+ mods = _UI.rich()
1037
+ Panel, Text = mods["Panel"], mods["Text"]
1038
+ body = Text()
1039
+ configured = bool(payload.get("configured"))
1040
+ rows: list[tuple[str, str, str | None]] = [("configured", "yes" if configured else "no", "bold white" if configured else "dim")]
1041
+ if configured:
1042
+ rows.extend(
1043
+ [
1044
+ ("command", str(payload.get("command") or "—"), "white"),
1045
+ ("cwd", str(payload.get("cwd") or "/home/ubuntu"), "dim"),
1046
+ ("port", str(payload.get("port") or "—"), "bold white"),
1047
+ ]
1048
+ )
1049
+ for label, value, style in rows:
1050
+ body.append(f"{label:<10}", style="dim")
1051
+ body.append(value, style=style or "white")
1052
+ body.append("\n")
1053
+ _UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
1054
+
1055
+
735
1056
  def _render_whoami_panel(payload: dict[str, Any]) -> None:
736
1057
  mods = _UI.rich()
737
1058
  Panel, Text = mods["Panel"], mods["Text"]
@@ -973,6 +1294,131 @@ def handle_info(config: RuntimeConfig, computer_id: str | None) -> int:
973
1294
  return report_success(result, lambda p: _render_computer_panel(p, title="computer"))
974
1295
 
975
1296
 
1297
+ def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
1298
+ if (err := _require_api_key(config)) is not None:
1299
+ return err
1300
+
1301
+ if computer_id is None:
1302
+ if not _interactive():
1303
+ return report_error("computer id is required")
1304
+ picked = _pick_computer(config, "Pick a computer")
1305
+ if picked is None:
1306
+ return 0
1307
+ computer_id = picked.get("id") or picked.get("slug")
1308
+ if not computer_id:
1309
+ return report_error("computer is missing an id")
1310
+
1311
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1312
+ computer_id = _resolve_computer_ref(client, computer_id)
1313
+ result = client.get_computer_network(computer_id)
1314
+ return report_success(result, lambda p: _render_network_panel(p, title="url"))
1315
+
1316
+
1317
+ def handle_startup_show(config: RuntimeConfig, computer_id: str | None) -> int:
1318
+ if (err := _require_api_key(config)) is not None:
1319
+ return err
1320
+
1321
+ if computer_id is None:
1322
+ if not _interactive():
1323
+ return report_error("computer id is required")
1324
+ picked = _pick_computer(config, "Pick a computer")
1325
+ if picked is None:
1326
+ return 0
1327
+ computer_id = picked.get("id") or picked.get("slug")
1328
+ if not computer_id:
1329
+ return report_error("computer is missing an id")
1330
+
1331
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1332
+ computer_id = _resolve_computer_ref(client, computer_id)
1333
+ result = client.get_startup_config(computer_id)
1334
+ return report_success(result, lambda p: _render_startup_panel(p, title="startup"))
1335
+
1336
+
1337
+ def handle_startup_set(
1338
+ config: RuntimeConfig,
1339
+ computer_id: str | None,
1340
+ command: str | None,
1341
+ cwd: str | None,
1342
+ port: int | None,
1343
+ ) -> int:
1344
+ if (err := _require_api_key(config)) is not None:
1345
+ return err
1346
+ if not computer_id:
1347
+ return report_error("computer id is required")
1348
+ if not command:
1349
+ return report_error("startup command is required")
1350
+ if port is None:
1351
+ return report_error("port is required")
1352
+ if port <= 0 or port > 65535:
1353
+ return report_error("port must be between 1 and 65535")
1354
+
1355
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1356
+ computer_id = _resolve_computer_ref(client, computer_id)
1357
+ result = _with_spinner(
1358
+ f"saving startup config for {computer_id}…",
1359
+ lambda: client.set_startup_config(computer_id, command=command, cwd=cwd, port=port),
1360
+ )
1361
+ return report_success(result, lambda p: _render_startup_panel(p, title="startup saved"))
1362
+
1363
+
1364
+ def handle_startup_clear(config: RuntimeConfig, computer_id: str | None) -> int:
1365
+ if (err := _require_api_key(config)) is not None:
1366
+ return err
1367
+ if not computer_id:
1368
+ return report_error("computer id is required")
1369
+
1370
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1371
+ computer_id = _resolve_computer_ref(client, computer_id)
1372
+ result = _with_spinner(
1373
+ f"clearing startup config for {computer_id}…",
1374
+ lambda: client.clear_startup_config(computer_id),
1375
+ )
1376
+
1377
+ def render(_: dict[str, Any]) -> None:
1378
+ _UI.console().print(f"[green]✓[/green] cleared durable startup config for [bold]{computer_id}[/bold]")
1379
+
1380
+ return report_success(result or {"configured": False}, render)
1381
+
1382
+
1383
+ def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
1384
+ if (err := _require_api_key(config)) is not None:
1385
+ return err
1386
+
1387
+ if computer_id is None:
1388
+ if not _interactive():
1389
+ return report_error("computer id is required")
1390
+ picked = _pick_computer(config, "Pick a computer")
1391
+ if picked is None:
1392
+ return 0
1393
+ computer_id = picked.get("id") or picked.get("slug")
1394
+ if not computer_id:
1395
+ return report_error("computer is missing an id")
1396
+
1397
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1398
+ computer_id = _resolve_computer_ref(client, computer_id)
1399
+ result = client.get_service_config(computer_id)
1400
+ return report_success(result, lambda p: _render_startup_panel(p, title="service"))
1401
+
1402
+
1403
+ def handle_service_clear(config: RuntimeConfig, computer_id: str | None) -> int:
1404
+ if (err := _require_api_key(config)) is not None:
1405
+ return err
1406
+ if not computer_id:
1407
+ return report_error("computer id is required")
1408
+
1409
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1410
+ computer_id = _resolve_computer_ref(client, computer_id)
1411
+ result = _with_spinner(
1412
+ f"clearing service for {computer_id}…",
1413
+ lambda: client.clear_service_config(computer_id),
1414
+ )
1415
+
1416
+ def render(_: dict[str, Any]) -> None:
1417
+ _UI.console().print(f"[green]✓[/green] cleared durable service for [bold]{computer_id}[/bold]")
1418
+
1419
+ return report_success(result or {"configured": False}, render)
1420
+
1421
+
976
1422
  def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
977
1423
  if (err := _require_api_key(config)) is not None:
978
1424
  return err
@@ -1139,24 +1585,239 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
1139
1585
 
1140
1586
  client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1141
1587
  computer_id = _resolve_computer_ref(client, computer_id)
1142
- _with_spinner(
1588
+ result = _with_spinner(
1143
1589
  f"publishing port {port} on {computer_id}…",
1144
1590
  lambda: client.publish_port(computer_id, port),
1145
1591
  )
1146
1592
 
1147
- def render(_: dict[str, Any]) -> None:
1593
+ def render(payload: dict[str, Any]) -> None:
1148
1594
  _UI.console().print(
1149
- f"[green]✓[/green] public url for [bold]{computer_id}[/bold] pinned to [bold]{port}[/bold]"
1595
+ f"[green]✓[/green] public url for [bold]{computer_id}[/bold] now points at durable app port [bold]{payload.get('published_port') or port}[/bold]"
1150
1596
  )
1597
+ url = payload.get("public_url")
1598
+ if url:
1599
+ _UI.console().print(f"[bold green]→[/bold green] [cyan]{url}[/cyan]")
1151
1600
 
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
- )
1601
+ return report_success(result, render)
1602
+
1603
+
1604
+ def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: list[str] | None) -> int:
1605
+ if (err := _require_api_key(config)) is not None:
1606
+ return err
1607
+ if not computer_id:
1608
+ return report_error("computer id is required")
1609
+ if not specs:
1610
+ return report_error("at least one port mapping is required")
1611
+
1612
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1613
+ resolved_id = _resolve_computer_ref(client, computer_id)
1614
+ existing_records = _load_proxy_records(clean_stale=True)
1615
+ existing_ports = {int(r.get("local_port") or 0) for r in existing_records}
1616
+ mappings: list[tuple[int, int]] = []
1617
+ seen_ports: set[int] = set()
1618
+ for spec in specs:
1619
+ local_port, remote_port = _parse_proxy_port_spec(spec)
1620
+ if local_port in existing_ports:
1621
+ return report_error(f"local port {local_port} is already in use by a runtime proxy")
1622
+ if local_port in seen_ports:
1623
+ return report_error(f"local port {local_port} was specified more than once")
1624
+ seen_ports.add(local_port)
1625
+ mappings.append((local_port, remote_port))
1626
+
1627
+ started: list[dict[str, Any]] = []
1628
+ started_ports: list[int] = []
1629
+ for local_port, remote_port in mappings:
1630
+ try:
1631
+ _spawn_proxy_agent(config.base_url, str(config.api_key or ""), resolved_id, computer_id, local_port, remote_port)
1632
+ except RuntimeAPIError as exc:
1633
+ if started_ports:
1634
+ _stop_proxy_ports(started_ports, stop_all=False)
1635
+ return report_error(str(exc), status_code=exc.status_code)
1636
+ started_ports.append(local_port)
1637
+ started.append({"computer_id": resolved_id, "computer": computer_id, "local_port": local_port, "remote_port": remote_port})
1638
+
1639
+ def render(payload: dict[str, Any]) -> None:
1640
+ _UI.console().print("[green]✓[/green] proxy started")
1641
+ for proxy in payload.get("proxies", []):
1642
+ _UI.console().print(
1643
+ f" [bold]localhost:{proxy['local_port']}[/bold] -> [cyan]{proxy['computer']}[/cyan]:{proxy['remote_port']}"
1644
+ )
1645
+
1646
+ return report_success({"proxies": started}, render)
1647
+
1648
+
1649
+ def handle_proxy_ls(config: RuntimeConfig) -> int:
1650
+ proxies = _load_proxy_records(clean_stale=True)
1651
+
1652
+ def render(payload: dict[str, Any]) -> None:
1653
+ mods = _UI.rich()
1654
+ Table = mods["Table"]
1655
+ table = Table(title="proxies")
1656
+ table.add_column("VM", style="white")
1657
+ table.add_column("Local", style="cyan")
1658
+ table.add_column("Remote", style="white")
1659
+ table.add_column("Status", style="green")
1660
+ for proxy in payload.get("proxies", []):
1661
+ table.add_row(
1662
+ str(proxy.get("display_name") or proxy.get("computer_id") or "—"),
1663
+ str(proxy.get("local_port") or "—"),
1664
+ str(proxy.get("remote_port") or "—"),
1665
+ str(proxy.get("status") or "unknown"),
1666
+ )
1667
+ _UI.console().print(table)
1668
+
1669
+ return report_success({"proxies": proxies}, render if _interactive() else None)
1670
+
1671
+
1672
+ def handle_proxy_stop(config: RuntimeConfig, local_ports: list[int] | None, *, stop_all: bool = False) -> int:
1673
+ if not stop_all and not local_ports:
1674
+ return report_error("pass one or more local ports, or use --all")
1675
+
1676
+ stopped = _stop_proxy_ports(local_ports or [], stop_all=stop_all)
1677
+
1678
+ def render(payload: dict[str, Any]) -> None:
1679
+ _UI.console().print("[green]✓[/green] proxy stopped")
1680
+ for proxy in payload.get("proxies", []):
1681
+ _UI.console().print(
1682
+ f" [bold]localhost:{proxy.get('local_port')}[/bold] -> [cyan]{proxy.get('display_name') or proxy.get('computer_id')}[/cyan]:{proxy.get('remote_port')}"
1683
+ )
1684
+
1685
+ return report_success({"proxies": stopped}, render if _interactive() else None)
1686
+
1687
+
1688
+ def _proxy_preflight(base_url: str, api_key: str, computer_id: str, remote_port: int) -> None:
1689
+ client = RuntimeClient(base_url=base_url, api_key=api_key)
1690
+ ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
1691
+ ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
1692
+ if not ws_url:
1693
+ raise RuntimeAPIError("proxy endpoint did not return a websocket url")
1694
+ with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None):
1695
+ return
1696
+
1697
+
1698
+
1699
+ def run_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
1700
+ if not api_key:
1701
+ print("proxy agent missing api key", file=sys.stderr)
1702
+ return 1
1703
+
1704
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1705
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1706
+ try:
1707
+ listener.bind(("127.0.0.1", local_port))
1708
+ listener.listen()
1709
+ listener.settimeout(1.0)
1710
+ except OSError as exc:
1711
+ print(f"proxy listen failed on localhost:{local_port}: {exc}", file=sys.stderr)
1712
+ return 1
1713
+
1714
+ try:
1715
+ _proxy_preflight(base_url, api_key, computer_id, remote_port)
1716
+ except Exception as exc:
1717
+ with contextlib.suppress(OSError):
1718
+ listener.close()
1719
+ print(f"proxy preflight failed for localhost:{local_port} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
1720
+ return 1
1721
+
1722
+ proxy_dir = _proxy_dir()
1723
+ proxy_dir.mkdir(parents=True, exist_ok=True)
1724
+ state = {
1725
+ "pid": os.getpid(),
1726
+ "computer_id": computer_id,
1727
+ "display_name": display_name,
1728
+ "local_port": local_port,
1729
+ "remote_port": remote_port,
1730
+ "started_at": datetime.now(timezone.utc).isoformat(),
1731
+ }
1732
+ _proxy_state_path(local_port).write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
1733
+
1734
+ stop_event = threading.Event()
1735
+
1736
+ def handle_signal(_signum: int, _frame: Any) -> None:
1737
+ stop_event.set()
1738
+ with contextlib.suppress(OSError):
1739
+ listener.close()
1740
+
1741
+ signal.signal(signal.SIGTERM, handle_signal)
1742
+ signal.signal(signal.SIGINT, handle_signal)
1743
+
1744
+ try:
1745
+ while not stop_event.is_set():
1746
+ try:
1747
+ conn, _ = listener.accept()
1748
+ except socket.timeout:
1749
+ continue
1750
+ except OSError:
1751
+ if stop_event.is_set():
1752
+ break
1753
+ continue
1754
+ thread = threading.Thread(
1755
+ target=_handle_proxy_client,
1756
+ args=(base_url, api_key, computer_id, remote_port, conn),
1757
+ daemon=True,
1758
+ )
1759
+ thread.start()
1760
+ finally:
1761
+ with contextlib.suppress(OSError):
1762
+ listener.close()
1763
+ _remove_proxy_state(local_port)
1764
+ return 0
1765
+
1766
+
1767
+ def _handle_proxy_client(base_url: str, api_key: str, computer_id: str, remote_port: int, conn: socket.socket) -> None:
1768
+ client = RuntimeClient(base_url=base_url, api_key=api_key)
1769
+ try:
1770
+ ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
1771
+ ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
1772
+ if not ws_url:
1773
+ raise RuntimeAPIError("proxy endpoint did not return a websocket url")
1774
+ with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None) as ws:
1775
+ stop = threading.Event()
1776
+
1777
+ def local_to_ws() -> None:
1778
+ try:
1779
+ while not stop.is_set():
1780
+ data = conn.recv(65536)
1781
+ if not data:
1782
+ break
1783
+ ws.send(data)
1784
+ except Exception:
1785
+ pass
1786
+ finally:
1787
+ stop.set()
1788
+ with contextlib.suppress(Exception):
1789
+ ws.close()
1790
+
1791
+ def ws_to_local() -> None:
1792
+ try:
1793
+ while not stop.is_set():
1794
+ data = ws.recv()
1795
+ if data is None:
1796
+ break
1797
+ if isinstance(data, str):
1798
+ chunk = data.encode()
1799
+ else:
1800
+ chunk = data
1801
+ if chunk:
1802
+ conn.sendall(chunk)
1803
+ except Exception:
1804
+ pass
1805
+ finally:
1806
+ stop.set()
1807
+ with contextlib.suppress(OSError):
1808
+ conn.shutdown(socket.SHUT_RDWR)
1809
+
1810
+ t1 = threading.Thread(target=local_to_ws, daemon=True)
1811
+ t2 = threading.Thread(target=ws_to_local, daemon=True)
1812
+ t1.start()
1813
+ t2.start()
1814
+ t1.join()
1815
+ t2.join()
1816
+ except Exception as exc:
1817
+ print(f"proxy connection failed for localhost:{conn.getsockname()[1]} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
1818
+ finally:
1819
+ with contextlib.suppress(OSError):
1820
+ conn.close()
1160
1821
 
1161
1822
 
1162
1823
  def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
@@ -1165,7 +1826,7 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
1165
1826
  f"preparing console for {computer_id}…",
1166
1827
  lambda: client.create_terminal_ticket(computer_id),
1167
1828
  )
1168
- ws_url = _resolve_terminal_ws_url(config.base_url, ticket_payload.get("ws_url"))
1829
+ ws_url = _resolve_ws_url(config.base_url, ticket_payload.get("ws_url"))
1169
1830
  if not ws_url:
1170
1831
  return report_error("terminal endpoint did not return a websocket url")
1171
1832
 
@@ -1179,21 +1840,6 @@ def _enter_computer(config: RuntimeConfig, computer_id: str) -> int:
1179
1840
  return report_error(f"terminal failed: {exc}")
1180
1841
 
1181
1842
 
1182
- def _resolve_terminal_ws_url(base_url: str, ws_url: Any) -> str:
1183
- text = str(ws_url or "").strip()
1184
- if not text:
1185
- return ""
1186
- if text.startswith("ws://") or text.startswith("wss://"):
1187
- return text
1188
-
1189
- parsed = urlparse(base_url)
1190
- scheme = "wss" if parsed.scheme == "https" else "ws"
1191
- root = f"{scheme}://{parsed.netloc}"
1192
- if text.startswith("/"):
1193
- return root + text
1194
- return urljoin(root + "/", text)
1195
-
1196
-
1197
1843
  def _run_terminal_session(ws_url: str) -> int:
1198
1844
  if not sys.stdin.isatty() or not sys.stdout.isatty():
1199
1845
  return report_error("enter requires an interactive terminal")
@@ -79,6 +79,34 @@ 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
+
85
+ def get_startup_config(self, computer_id: str) -> dict[str, Any]:
86
+ return self._request("GET", f"/api/computers/{computer_id}/startup", auth_required=True)
87
+
88
+ def get_service_config(self, computer_id: str) -> dict[str, Any]:
89
+ return self.get_startup_config(computer_id)
90
+
91
+ def set_startup_config(
92
+ self,
93
+ computer_id: str,
94
+ *,
95
+ command: str,
96
+ cwd: str | None = None,
97
+ port: int,
98
+ ) -> dict[str, Any]:
99
+ body: dict[str, Any] = {"command": command, "port": port}
100
+ if cwd is not None:
101
+ body["cwd"] = cwd
102
+ return self._request("PUT", f"/api/computers/{computer_id}/startup", json=body, auth_required=True)
103
+
104
+ def clear_startup_config(self, computer_id: str) -> dict[str, Any]:
105
+ return self._request("DELETE", f"/api/computers/{computer_id}/startup", auth_required=True)
106
+
107
+ def clear_service_config(self, computer_id: str) -> dict[str, Any]:
108
+ return self.clear_startup_config(computer_id)
109
+
82
110
  def start_computer(self, computer_id: str) -> dict[str, Any]:
83
111
  return self._request(
84
112
  "POST",
@@ -98,6 +126,17 @@ class RuntimeClient:
98
126
  timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
99
127
  )
100
128
 
129
+ def create_proxy_ticket(self, computer_id: str, port: int) -> dict[str, Any]:
130
+ if port <= 0 or port > 65535:
131
+ raise RuntimeAPIError("port must be between 1 and 65535")
132
+ return self._request(
133
+ "POST",
134
+ f"/api/computers/{computer_id}/proxy-ticket",
135
+ json={"port": port},
136
+ auth_required=True,
137
+ timeout=max(self.timeout, DEFAULT_WARMUP_TIMEOUT),
138
+ )
139
+
101
140
  def run_command(
102
141
  self,
103
142
  computer_id: str,
@@ -122,17 +161,12 @@ class RuntimeClient:
122
161
  def publish_port(self, computer_id: str, port: int) -> dict[str, Any]:
123
162
  if port <= 0 or port > 65535:
124
163
  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,
164
+ return self._request(
165
+ "POST",
166
+ f"/api/computers/{computer_id}/publish",
167
+ json={"port": port},
168
+ auth_required=True,
131
169
  )
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
170
 
137
171
  def _request(
138
172
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.9
3
+ Version: 0.4.11
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
@@ -54,16 +54,27 @@ runtime logout
54
54
  runtime login --api-key rt_live_...
55
55
 
56
56
  # Computers
57
- runtime create # in a real TTY, creates then drops you into the console
57
+ runtime create # creates a computer with the starter app already published
58
58
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
59
59
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
60
60
  runtime list
61
61
  runtime info <id>
62
62
  runtime start <id>
63
- runtime run <id> "echo hello"
63
+ runtime run <id> "echo hello" # one-shot foreground command only
64
64
  runtime run <id> "apt install -y nodejs" --uid 0
65
- runtime publish <id> 3000
65
+ runtime startup show <id>
66
+ runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
67
+ runtime startup clear <id> # low-level durable service config
68
+ runtime service show <id> # user-facing alias for the durable published app
69
+ runtime service clear <id>
70
+ runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
66
71
  runtime delete <id>
72
+
73
+ # Inside a running computer, the helper installed by Runtime can manage the
74
+ # durable app without leaving the sandbox:
75
+ # runtime-env publish 3000
76
+ # runtime-env service show
77
+ # runtime-env service clear
67
78
  ```
68
79
 
69
80
  ## Python
@@ -73,12 +84,12 @@ from runtime_sdk import RuntimeClient
73
84
 
74
85
  client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
75
86
 
76
- # Create a computer
87
+ # Create a computer. New computers start with the starter app already published.
77
88
  computer = client.create_computer()
78
89
  print(computer["public_url"]) # https://goldbird.runruntime.dev
79
90
 
80
- # Or create one with a durable startup command.
81
- # That startup config is replayed after cold restore / auto-wake.
91
+ # Or create one with an explicit durable app command.
92
+ # That saved service is replayed after cold restore / start.
82
93
  app = client.create_computer(
83
94
  slug="myapp",
84
95
  command="python3 app.py",
@@ -93,7 +104,8 @@ print(result["stdout"])
93
104
  # Wake a cold computer explicitly
94
105
  client.start_computer(app["id"])
95
106
 
96
- # Pin the public URL to a local app port
107
+ # Promote the running app on a local port to the durable public app.
108
+ # Runtime inspects the listening process and saves its command + cwd when possible.
97
109
  client.publish_port(computer["id"], 3000)
98
110
 
99
111
  # List, info, delete
@@ -120,10 +132,16 @@ make smoke
120
132
  Use `make deploy` instead of `make sync` when migrations, env
121
133
  files, Caddy, or systemd units changed.
122
134
 
123
- Cold restore and public auto-wake replay the saved startup command only for
124
- computers created with `--command` + `--port` (or the SDK `command`/`port`
125
- arguments). If you start an app later via a one-off `runtime run`, the
126
- filesystem is restored after going cold, but that ad-hoc process is not.
135
+ Cold restore and explicit `runtime start` replay the saved published app command.
136
+ New computers seed that durable app from the starter workspace. Later,
137
+ `runtime publish <id> <port>` can promote a running listener into the saved
138
+ public app definition by inspecting the live process. Inside a running computer,
139
+ `runtime-env publish <port>` does the same thing using a computer-scoped token
140
+ installed by Runtime. `runtime service show|clear` and `runtime-env service
141
+ show|clear` expose that same durable app state directly. The low-level
142
+ `runtime startup ...` commands still map to the same durable state. A one-off
143
+ `runtime run` stays one-shot: the filesystem is restored after going cold, but
144
+ that ad-hoc process is not.
127
145
 
128
146
  Run the SDK unit tests through the backend project environment:
129
147
 
File without changes