runtime-sdk 0.4.10__tar.gz → 0.4.12__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.10
3
+ Version: 0.4.12
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
@@ -52,18 +52,32 @@ runtime verify 123456
52
52
  runtime whoami
53
53
  runtime logout
54
54
  runtime login --api-key rt_live_...
55
+ runtime api-keys list
56
+ runtime api-keys create "my laptop"
57
+ runtime api-keys revoke 123
55
58
 
56
59
  # Computers
57
- runtime create # in a real TTY, creates then drops you into the console
60
+ runtime create # creates a computer with the starter app already published
58
61
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
59
62
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
60
63
  runtime list
61
64
  runtime info <id>
62
65
  runtime start <id>
63
- runtime run <id> "echo hello"
66
+ runtime run <id> "echo hello" # one-shot foreground command only
64
67
  runtime run <id> "apt install -y nodejs" --uid 0
65
- runtime publish <id> 3000
68
+ runtime startup show <id>
69
+ runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
70
+ runtime startup clear <id> # low-level durable service config
71
+ runtime service show <id> # user-facing alias for the durable published app
72
+ runtime service clear <id>
73
+ runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
66
74
  runtime delete <id>
75
+
76
+ # Inside a running computer, the helper installed by Runtime can manage the
77
+ # durable app without leaving the sandbox:
78
+ # runtime-env publish 3000
79
+ # runtime-env service show
80
+ # runtime-env service clear
67
81
  ```
68
82
 
69
83
  ## Python
@@ -73,12 +87,12 @@ from runtime_sdk import RuntimeClient
73
87
 
74
88
  client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
75
89
 
76
- # Create a computer
90
+ # Create a computer. New computers start with the starter app already published.
77
91
  computer = client.create_computer()
78
92
  print(computer["public_url"]) # https://goldbird.runruntime.dev
79
93
 
80
- # Or create one with a durable startup command.
81
- # That startup config is replayed after cold restore / auto-wake.
94
+ # Or create one with an explicit durable app command.
95
+ # That saved service is replayed after cold restore / start.
82
96
  app = client.create_computer(
83
97
  slug="myapp",
84
98
  command="python3 app.py",
@@ -93,7 +107,8 @@ print(result["stdout"])
93
107
  # Wake a cold computer explicitly
94
108
  client.start_computer(app["id"])
95
109
 
96
- # Pin the public URL to a local app port
110
+ # Promote the running app on a local port to the durable public app.
111
+ # Runtime inspects the listening process and saves its command + cwd when possible.
97
112
  client.publish_port(computer["id"], 3000)
98
113
 
99
114
  # List, info, delete
@@ -120,10 +135,16 @@ make smoke
120
135
  Use `make deploy` instead of `make sync` when migrations, env
121
136
  files, Caddy, or systemd units changed.
122
137
 
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.
138
+ Cold restore and explicit `runtime start` replay the saved published app command.
139
+ New computers seed that durable app from the starter workspace. Later,
140
+ `runtime publish <id> <port>` can promote a running listener into the saved
141
+ public app definition by inspecting the live process. Inside a running computer,
142
+ `runtime-env publish <port>` does the same thing using a computer-scoped token
143
+ installed by Runtime. `runtime service show|clear` and `runtime-env service
144
+ show|clear` expose that same durable app state directly. The low-level
145
+ `runtime startup ...` commands still map to the same durable state. A one-off
146
+ `runtime run` stays one-shot: the filesystem is restored after going cold, but
147
+ that ad-hoc process is not.
127
148
 
128
149
  Run the SDK unit tests through the backend project environment:
129
150
 
@@ -33,18 +33,32 @@ runtime verify 123456
33
33
  runtime whoami
34
34
  runtime logout
35
35
  runtime login --api-key rt_live_...
36
+ runtime api-keys list
37
+ runtime api-keys create "my laptop"
38
+ runtime api-keys revoke 123
36
39
 
37
40
  # Computers
38
- runtime create # in a real TTY, creates then drops you into the console
41
+ runtime create # creates a computer with the starter app already published
39
42
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
40
43
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
41
44
  runtime list
42
45
  runtime info <id>
43
46
  runtime start <id>
44
- runtime run <id> "echo hello"
47
+ runtime run <id> "echo hello" # one-shot foreground command only
45
48
  runtime run <id> "apt install -y nodejs" --uid 0
46
- runtime publish <id> 3000
49
+ runtime startup show <id>
50
+ runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
51
+ runtime startup clear <id> # low-level durable service config
52
+ runtime service show <id> # user-facing alias for the durable published app
53
+ runtime service clear <id>
54
+ runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
47
55
  runtime delete <id>
56
+
57
+ # Inside a running computer, the helper installed by Runtime can manage the
58
+ # durable app without leaving the sandbox:
59
+ # runtime-env publish 3000
60
+ # runtime-env service show
61
+ # runtime-env service clear
48
62
  ```
49
63
 
50
64
  ## Python
@@ -54,12 +68,12 @@ from runtime_sdk import RuntimeClient
54
68
 
55
69
  client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
56
70
 
57
- # Create a computer
71
+ # Create a computer. New computers start with the starter app already published.
58
72
  computer = client.create_computer()
59
73
  print(computer["public_url"]) # https://goldbird.runruntime.dev
60
74
 
61
- # Or create one with a durable startup command.
62
- # That startup config is replayed after cold restore / auto-wake.
75
+ # Or create one with an explicit durable app command.
76
+ # That saved service is replayed after cold restore / start.
63
77
  app = client.create_computer(
64
78
  slug="myapp",
65
79
  command="python3 app.py",
@@ -74,7 +88,8 @@ print(result["stdout"])
74
88
  # Wake a cold computer explicitly
75
89
  client.start_computer(app["id"])
76
90
 
77
- # Pin the public URL to a local app port
91
+ # Promote the running app on a local port to the durable public app.
92
+ # Runtime inspects the listening process and saves its command + cwd when possible.
78
93
  client.publish_port(computer["id"], 3000)
79
94
 
80
95
  # List, info, delete
@@ -101,10 +116,16 @@ make smoke
101
116
  Use `make deploy` instead of `make sync` when migrations, env
102
117
  files, Caddy, or systemd units changed.
103
118
 
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.
119
+ Cold restore and explicit `runtime start` replay the saved published app command.
120
+ New computers seed that durable app from the starter workspace. Later,
121
+ `runtime publish <id> <port>` can promote a running listener into the saved
122
+ public app definition by inspecting the live process. Inside a running computer,
123
+ `runtime-env publish <port>` does the same thing using a computer-scoped token
124
+ installed by Runtime. `runtime service show|clear` and `runtime-env service
125
+ show|clear` expose that same durable app state directly. The low-level
126
+ `runtime startup ...` commands still map to the same durable state. A one-off
127
+ `runtime run` stays one-shot: the filesystem is restored after going cold, but
128
+ that ad-hoc process is not.
108
129
 
109
130
  Run the SDK unit tests through the backend project environment:
110
131
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.10"
3
+ version = "0.4.12"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -165,16 +165,24 @@ def build_parser() -> argparse.ArgumentParser:
165
165
  subparsers.add_parser("whoami")
166
166
  subparsers.add_parser("logout")
167
167
 
168
- create_cmd = subparsers.add_parser("create", help="Create a new computer")
168
+ api_keys_cmd = subparsers.add_parser("api-keys", help="Manage Runtime API keys")
169
+ api_keys_subparsers = api_keys_cmd.add_subparsers(dest="api_keys_action", required=True)
170
+ api_keys_subparsers.add_parser("list", aliases=["ls"], help="List API keys")
171
+ api_keys_create = api_keys_subparsers.add_parser("create", help="Create a new API key")
172
+ api_keys_create.add_argument("label", nargs="?", default=None, help="Human-friendly label for the new API key")
173
+ api_keys_revoke = api_keys_subparsers.add_parser("revoke", help="Revoke an API key")
174
+ api_keys_revoke.add_argument("id", nargs="?", default=None, help="API key id")
175
+
176
+ create_cmd = subparsers.add_parser("create", help="Create a new computer with the starter app published by default")
169
177
  create_cmd.add_argument(
170
178
  "name",
171
179
  nargs="?",
172
180
  default=None,
173
181
  help="Subdomain name (e.g. redsox → redsox.runruntime.dev). Random if skipped.",
174
182
  )
175
- create_cmd.add_argument("--command", dest="startup_command", help="App startup command to run after create")
176
- create_cmd.add_argument("--cwd", help="Working directory for the startup command")
177
- create_cmd.add_argument("--port", type=int, help="App port to publish after startup")
183
+ create_cmd.add_argument("--command", dest="startup_command", help="Durable startup command to save on create")
184
+ create_cmd.add_argument("--cwd", help="Working directory for the durable startup command")
185
+ create_cmd.add_argument("--port", type=int, help="App port to publish for durable startup")
178
186
  subparsers.add_parser("list", aliases=["ls"], help="List computers")
179
187
 
180
188
  enter_cmd = subparsers.add_parser("enter", help="Enter a computer's console")
@@ -192,7 +200,7 @@ def build_parser() -> argparse.ArgumentParser:
192
200
  delete_cmd = subparsers.add_parser("delete", aliases=["rm"], help="Delete a computer")
193
201
  delete_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
194
202
 
195
- run_cmd = subparsers.add_parser("run", help="Run a command in a computer")
203
+ run_cmd = subparsers.add_parser("run", help="Run a one-shot command in a computer")
196
204
  run_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
197
205
  run_cmd.add_argument(
198
206
  "run_command",
@@ -207,11 +215,30 @@ def build_parser() -> argparse.ArgumentParser:
207
215
  run_cmd.add_argument("--shell", help="Shell to use")
208
216
 
209
217
  publish_cmd = subparsers.add_parser(
210
- "publish", help="Pin the public URL to a local app port"
218
+ "publish", help="Promote the running app on a local port into the durable public app"
211
219
  )
212
220
  publish_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
213
221
  publish_cmd.add_argument("port", nargs="?", type=int, default=None, help="Local app port")
214
222
 
223
+ startup_cmd = subparsers.add_parser("startup", help="Manage durable startup config")
224
+ startup_subparsers = startup_cmd.add_subparsers(dest="startup_action", required=True)
225
+ startup_show = startup_subparsers.add_parser("show", help="Show durable startup config")
226
+ startup_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
227
+ startup_set = startup_subparsers.add_parser("set", help="Set durable startup config")
228
+ startup_set.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
229
+ startup_set.add_argument("--command", dest="startup_value_command", required=True, help="Durable startup command")
230
+ startup_set.add_argument("--cwd", help="Working directory for the durable startup command")
231
+ startup_set.add_argument("--port", required=True, type=int, help="App port to publish for durable startup")
232
+ startup_clear = startup_subparsers.add_parser("clear", help="Clear durable startup config")
233
+ startup_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
234
+
235
+ service_cmd = subparsers.add_parser("service", help="Manage the durable published app service")
236
+ service_subparsers = service_cmd.add_subparsers(dest="service_action", required=True)
237
+ service_show = service_subparsers.add_parser("show", help="Show the durable published app service")
238
+ service_show.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
239
+ service_clear = service_subparsers.add_parser("clear", help="Clear the durable published app service")
240
+ service_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
241
+
215
242
  proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
216
243
  proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
217
244
  proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
@@ -261,6 +288,14 @@ def main(argv: list[str] | None = None) -> int:
261
288
  return handle_whoami(config)
262
289
  if args.command == "logout":
263
290
  return handle_logout(config)
291
+ if args.command == "api-keys":
292
+ if args.api_keys_action in ("list", "ls"):
293
+ return handle_api_keys_list(config)
294
+ if args.api_keys_action == "create":
295
+ return handle_api_keys_create(config, args.label)
296
+ if args.api_keys_action == "revoke":
297
+ return handle_api_keys_revoke(config, args.id)
298
+ return report_error("unknown api-keys command")
264
299
  if args.command == "create":
265
300
  return handle_create(
266
301
  config,
@@ -287,6 +322,20 @@ def main(argv: list[str] | None = None) -> int:
287
322
  )
288
323
  if args.command == "publish":
289
324
  return handle_publish(config, args.id, args.port)
325
+ if args.command == "startup":
326
+ if args.startup_action == "show":
327
+ return handle_startup_show(config, args.id)
328
+ if args.startup_action == "set":
329
+ return handle_startup_set(config, args.id, args.startup_value_command, args.cwd, args.port)
330
+ if args.startup_action == "clear":
331
+ return handle_startup_clear(config, args.id)
332
+ return report_error("unknown startup command")
333
+ if args.command == "service":
334
+ if args.service_action == "show":
335
+ return handle_service_show(config, args.id)
336
+ if args.service_action == "clear":
337
+ return handle_service_clear(config, args.id)
338
+ return report_error("unknown service command")
290
339
  if args.command == "proxy":
291
340
  if args.proxy_command == "start":
292
341
  return handle_proxy_start(config, args.id, args.ports)
@@ -510,7 +559,7 @@ def _spawn_proxy_agent(base_url: str, api_key: str, computer_id: str, display_na
510
559
  finally:
511
560
  log_file.close()
512
561
 
513
- deadline = time.time() + 4.0
562
+ deadline = time.time() + max(4.0, DEFAULT_WARMUP_TIMEOUT + 2.0)
514
563
  state_path = _proxy_state_path(local_port)
515
564
  while time.time() < deadline:
516
565
  if state_path.exists():
@@ -664,7 +713,7 @@ def _resolve_computer_ref(client: RuntimeClient, ref: str) -> str:
664
713
  return resolved or ref
665
714
  if len(matches) > 1:
666
715
  raise RuntimeAPIError(f"multiple computers matched {ref!r}; use the exact computer id")
667
- return ref
716
+ raise RuntimeAPIError(f"computer {ref!r} not found")
668
717
 
669
718
 
670
719
 
@@ -682,8 +731,8 @@ def _computer_url_label(c: dict[str, Any]) -> str:
682
731
 
683
732
 
684
733
 
685
- def _computer_created_label(c: dict[str, Any]) -> str:
686
- raw = str(c.get("created_at") or "").strip()
734
+ def _relative_time_label(value: Any) -> str:
735
+ raw = str(value or "").strip()
687
736
  if not raw:
688
737
  return ""
689
738
  try:
@@ -705,6 +754,11 @@ def _computer_created_label(c: dict[str, Any]) -> str:
705
754
 
706
755
 
707
756
 
757
+ def _computer_created_label(c: dict[str, Any]) -> str:
758
+ return _relative_time_label(c.get("created_at"))
759
+
760
+
761
+
708
762
  def _computer_status_style(status: str) -> str:
709
763
  if "cold" in status:
710
764
  return "bold cyan"
@@ -999,6 +1053,27 @@ def _render_network_panel(payload: dict[str, Any], *, title: str = "url") -> Non
999
1053
  _UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
1000
1054
 
1001
1055
 
1056
+ def _render_startup_panel(payload: dict[str, Any], *, title: str = "startup") -> None:
1057
+ mods = _UI.rich()
1058
+ Panel, Text = mods["Panel"], mods["Text"]
1059
+ body = Text()
1060
+ configured = bool(payload.get("configured"))
1061
+ rows: list[tuple[str, str, str | None]] = [("configured", "yes" if configured else "no", "bold white" if configured else "dim")]
1062
+ if configured:
1063
+ rows.extend(
1064
+ [
1065
+ ("command", str(payload.get("command") or "—"), "white"),
1066
+ ("cwd", str(payload.get("cwd") or "/home/ubuntu"), "dim"),
1067
+ ("port", str(payload.get("port") or "—"), "bold white"),
1068
+ ]
1069
+ )
1070
+ for label, value, style in rows:
1071
+ body.append(f"{label:<10}", style="dim")
1072
+ body.append(value, style=style or "white")
1073
+ body.append("\n")
1074
+ _UI.console().print(Panel(body, title=title, border_style="cyan", expand=False, padding=(0, 1)))
1075
+
1076
+
1002
1077
  def _render_whoami_panel(payload: dict[str, Any]) -> None:
1003
1078
  mods = _UI.rich()
1004
1079
  Panel, Text = mods["Panel"], mods["Text"]
@@ -1018,6 +1093,84 @@ def _render_whoami_panel(payload: dict[str, Any]) -> None:
1018
1093
  _UI.console().print(Panel(body, title="whoami", border_style="cyan", expand=False))
1019
1094
 
1020
1095
 
1096
+
1097
+ def _api_key_status_label(key: dict[str, Any]) -> str:
1098
+ if key.get("revoked_at"):
1099
+ return "revoked"
1100
+ if key.get("last_used_at"):
1101
+ return "used"
1102
+ return "unused"
1103
+
1104
+
1105
+
1106
+ def _api_key_status_style(status: str) -> str:
1107
+ if status == "revoked":
1108
+ return "bold red"
1109
+ if status == "used":
1110
+ return "bold green"
1111
+ return "yellow"
1112
+
1113
+
1114
+
1115
+ def _render_api_keys_table(payload: dict[str, Any]) -> None:
1116
+ mods = _UI.rich()
1117
+ Table = mods["Table"]
1118
+ api_keys = payload.get("api_keys") or []
1119
+ if not isinstance(api_keys, list) or not api_keys:
1120
+ _UI.console().print("[yellow]no api keys found[/yellow]")
1121
+ return
1122
+
1123
+ table = Table(title="API keys", expand=True, box=mods["box"].SIMPLE_HEAVY)
1124
+ table.add_column("ID", style="dim", no_wrap=True)
1125
+ table.add_column("Label", style="bold white")
1126
+ table.add_column("Prefix", style="white", no_wrap=True)
1127
+ table.add_column("Created", style="dim", no_wrap=True, justify="right")
1128
+ table.add_column("Last used", style="dim", no_wrap=True, justify="right")
1129
+ table.add_column("Status", no_wrap=True)
1130
+
1131
+ for entry in api_keys:
1132
+ if not isinstance(entry, dict):
1133
+ continue
1134
+ status = _api_key_status_label(entry)
1135
+ style = _api_key_status_style(status)
1136
+ table.add_row(
1137
+ str(entry.get("id") or "—"),
1138
+ str(entry.get("label") or "—"),
1139
+ str(entry.get("key_prefix") or "—"),
1140
+ _relative_time_label(entry.get("created_at")) or "—",
1141
+ _relative_time_label(entry.get("last_used_at")) or "—",
1142
+ f"[{style}]{status}[/{style}]",
1143
+ )
1144
+
1145
+ _UI.console().print(table)
1146
+
1147
+
1148
+
1149
+ def _render_api_key_created(payload: dict[str, Any]) -> None:
1150
+ mods = _UI.rich()
1151
+ Panel, Text = mods["Panel"], mods["Text"]
1152
+ key = payload.get("key") or {}
1153
+ secret = str(payload.get("api_key") or "")
1154
+ body = Text()
1155
+ rows = [
1156
+ ("id", key.get("id")),
1157
+ ("label", key.get("label")),
1158
+ ("prefix", key.get("key_prefix")),
1159
+ ]
1160
+ for label, value in rows:
1161
+ if value is None:
1162
+ continue
1163
+ body.append(f"{label:<12}", style="dim")
1164
+ body.append(f"{value}\n")
1165
+ if secret:
1166
+ body.append("save now ", style="bold yellow")
1167
+ body.append("this secret is only shown once\n", style="yellow")
1168
+ body.append("api key ", style="dim")
1169
+ body.append(secret, style="bold white")
1170
+ body.append("\n")
1171
+ _UI.console().print(Panel(body, title="api key created", border_style="cyan", expand=False))
1172
+
1173
+
1021
1174
  # --------------------------------------------------------------------------- #
1022
1175
  # Command handlers #
1023
1176
  # --------------------------------------------------------------------------- #
@@ -1166,6 +1319,56 @@ def handle_logout(config: RuntimeConfig) -> int:
1166
1319
  return report_success({"message": message}, render)
1167
1320
 
1168
1321
 
1322
+
1323
+ def handle_api_keys_list(config: RuntimeConfig) -> int:
1324
+ if (err := _require_api_key(config)) is not None:
1325
+ return err
1326
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1327
+ return report_success(client.list_api_keys(), _render_api_keys_table)
1328
+
1329
+
1330
+
1331
+ def handle_api_keys_create(config: RuntimeConfig, label: str | None) -> int:
1332
+ if (err := _require_api_key(config)) is not None:
1333
+ return err
1334
+ if label is None:
1335
+ if not _interactive():
1336
+ return report_error("label is required")
1337
+ label = _prompt_text("API key label")
1338
+ if not label:
1339
+ return report_error("label is required")
1340
+
1341
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1342
+ result = client.create_api_key(label)
1343
+ return report_success(result, _render_api_key_created)
1344
+
1345
+
1346
+
1347
+ def handle_api_keys_revoke(config: RuntimeConfig, key_id: str | None) -> int:
1348
+ if (err := _require_api_key(config)) is not None:
1349
+ return err
1350
+ if key_id is None:
1351
+ if not _interactive():
1352
+ return report_error("api key id is required")
1353
+ key_id = _prompt_text("API key id")
1354
+ if not key_id:
1355
+ return report_error("api key id is required")
1356
+
1357
+ try:
1358
+ resolved_key_id = int(str(key_id).strip())
1359
+ except ValueError:
1360
+ return report_error("api key id must be a number")
1361
+
1362
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1363
+ client.revoke_api_key(resolved_key_id)
1364
+
1365
+ def render(_: dict[str, Any]) -> None:
1366
+ _UI.console().print(f"[green]✓[/green] api key {resolved_key_id} revoked")
1367
+
1368
+ return report_success({"id": resolved_key_id, "message": f"api key {resolved_key_id} revoked"}, render)
1369
+
1370
+
1371
+
1169
1372
  def handle_create(
1170
1373
  config: RuntimeConfig,
1171
1374
  name: str | None,
@@ -1260,6 +1463,111 @@ def handle_url(config: RuntimeConfig, computer_id: str | None) -> int:
1260
1463
  return report_success(result, lambda p: _render_network_panel(p, title="url"))
1261
1464
 
1262
1465
 
1466
+ def handle_startup_show(config: RuntimeConfig, computer_id: str | None) -> int:
1467
+ if (err := _require_api_key(config)) is not None:
1468
+ return err
1469
+
1470
+ if computer_id is None:
1471
+ if not _interactive():
1472
+ return report_error("computer id is required")
1473
+ picked = _pick_computer(config, "Pick a computer")
1474
+ if picked is None:
1475
+ return 0
1476
+ computer_id = picked.get("id") or picked.get("slug")
1477
+ if not computer_id:
1478
+ return report_error("computer is missing an id")
1479
+
1480
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1481
+ computer_id = _resolve_computer_ref(client, computer_id)
1482
+ result = client.get_startup_config(computer_id)
1483
+ return report_success(result, lambda p: _render_startup_panel(p, title="startup"))
1484
+
1485
+
1486
+ def handle_startup_set(
1487
+ config: RuntimeConfig,
1488
+ computer_id: str | None,
1489
+ command: str | None,
1490
+ cwd: str | None,
1491
+ port: int | None,
1492
+ ) -> int:
1493
+ if (err := _require_api_key(config)) is not None:
1494
+ return err
1495
+ if not computer_id:
1496
+ return report_error("computer id is required")
1497
+ if not command:
1498
+ return report_error("startup command is required")
1499
+ if port is None:
1500
+ return report_error("port is required")
1501
+ if port <= 0 or port > 65535:
1502
+ return report_error("port must be between 1 and 65535")
1503
+
1504
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1505
+ computer_id = _resolve_computer_ref(client, computer_id)
1506
+ result = _with_spinner(
1507
+ f"saving startup config for {computer_id}…",
1508
+ lambda: client.set_startup_config(computer_id, command=command, cwd=cwd, port=port),
1509
+ )
1510
+ return report_success(result, lambda p: _render_startup_panel(p, title="startup saved"))
1511
+
1512
+
1513
+ def handle_startup_clear(config: RuntimeConfig, computer_id: str | None) -> int:
1514
+ if (err := _require_api_key(config)) is not None:
1515
+ return err
1516
+ if not computer_id:
1517
+ return report_error("computer id is required")
1518
+
1519
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1520
+ computer_id = _resolve_computer_ref(client, computer_id)
1521
+ result = _with_spinner(
1522
+ f"clearing startup config for {computer_id}…",
1523
+ lambda: client.clear_startup_config(computer_id),
1524
+ )
1525
+
1526
+ def render(_: dict[str, Any]) -> None:
1527
+ _UI.console().print(f"[green]✓[/green] cleared durable startup config for [bold]{computer_id}[/bold]")
1528
+
1529
+ return report_success(result or {"configured": False}, render)
1530
+
1531
+
1532
+ def handle_service_show(config: RuntimeConfig, computer_id: str | None) -> int:
1533
+ if (err := _require_api_key(config)) is not None:
1534
+ return err
1535
+
1536
+ if computer_id is None:
1537
+ if not _interactive():
1538
+ return report_error("computer id is required")
1539
+ picked = _pick_computer(config, "Pick a computer")
1540
+ if picked is None:
1541
+ return 0
1542
+ computer_id = picked.get("id") or picked.get("slug")
1543
+ if not computer_id:
1544
+ return report_error("computer is missing an id")
1545
+
1546
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1547
+ computer_id = _resolve_computer_ref(client, computer_id)
1548
+ result = client.get_service_config(computer_id)
1549
+ return report_success(result, lambda p: _render_startup_panel(p, title="service"))
1550
+
1551
+
1552
+ def handle_service_clear(config: RuntimeConfig, computer_id: str | None) -> int:
1553
+ if (err := _require_api_key(config)) is not None:
1554
+ return err
1555
+ if not computer_id:
1556
+ return report_error("computer id is required")
1557
+
1558
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
1559
+ computer_id = _resolve_computer_ref(client, computer_id)
1560
+ result = _with_spinner(
1561
+ f"clearing service for {computer_id}…",
1562
+ lambda: client.clear_service_config(computer_id),
1563
+ )
1564
+
1565
+ def render(_: dict[str, Any]) -> None:
1566
+ _UI.console().print(f"[green]✓[/green] cleared durable service for [bold]{computer_id}[/bold]")
1567
+
1568
+ return report_success(result or {"configured": False}, render)
1569
+
1570
+
1263
1571
  def handle_start(config: RuntimeConfig, computer_id: str | None) -> int:
1264
1572
  if (err := _require_api_key(config)) is not None:
1265
1573
  return err
@@ -1433,7 +1741,7 @@ def handle_publish(config: RuntimeConfig, computer_id: str | None, port: int | N
1433
1741
 
1434
1742
  def render(payload: dict[str, Any]) -> None:
1435
1743
  _UI.console().print(
1436
- f"[green]✓[/green] public url for [bold]{computer_id}[/bold] pinned to [bold]{payload.get('published_port') or port}[/bold]"
1744
+ f"[green]✓[/green] public url for [bold]{computer_id}[/bold] now points at durable app port [bold]{payload.get('published_port') or port}[/bold]"
1437
1745
  )
1438
1746
  url = payload.get("public_url")
1439
1747
  if url:
@@ -1474,6 +1782,10 @@ def handle_proxy_start(config: RuntimeConfig, computer_id: str | None, specs: li
1474
1782
  if started_ports:
1475
1783
  _stop_proxy_ports(started_ports, stop_all=False)
1476
1784
  return report_error(str(exc), status_code=exc.status_code)
1785
+ except Exception as exc:
1786
+ if started_ports:
1787
+ _stop_proxy_ports(started_ports, stop_all=False)
1788
+ return report_error(str(exc))
1477
1789
  started_ports.append(local_port)
1478
1790
  started.append({"computer_id": resolved_id, "computer": computer_id, "local_port": local_port, "remote_port": remote_port})
1479
1791
 
@@ -1526,6 +1838,17 @@ def handle_proxy_stop(config: RuntimeConfig, local_ports: list[int] | None, *, s
1526
1838
  return report_success({"proxies": stopped}, render if _interactive() else None)
1527
1839
 
1528
1840
 
1841
+ def _proxy_preflight(base_url: str, api_key: str, computer_id: str, remote_port: int) -> None:
1842
+ client = RuntimeClient(base_url=base_url, api_key=api_key)
1843
+ ticket_payload = client.create_proxy_ticket(computer_id, remote_port)
1844
+ ws_url = _resolve_ws_url(base_url, ticket_payload.get("ws_url"))
1845
+ if not ws_url:
1846
+ raise RuntimeAPIError("proxy endpoint did not return a websocket url")
1847
+ with websocket_connect(ws_url, open_timeout=DEFAULT_WARMUP_TIMEOUT, close_timeout=1, max_size=None):
1848
+ return
1849
+
1850
+
1851
+
1529
1852
  def run_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name: str, local_port: int, remote_port: int) -> int:
1530
1853
  if not api_key:
1531
1854
  print("proxy agent missing api key", file=sys.stderr)
@@ -1541,6 +1864,14 @@ def run_proxy_agent(base_url: str, api_key: str, computer_id: str, display_name:
1541
1864
  print(f"proxy listen failed on localhost:{local_port}: {exc}", file=sys.stderr)
1542
1865
  return 1
1543
1866
 
1867
+ try:
1868
+ _proxy_preflight(base_url, api_key, computer_id, remote_port)
1869
+ except Exception as exc:
1870
+ with contextlib.suppress(OSError):
1871
+ listener.close()
1872
+ print(f"proxy preflight failed for localhost:{local_port} -> {computer_id}:{remote_port}: {exc}", file=sys.stderr)
1873
+ return 1
1874
+
1544
1875
  proxy_dir = _proxy_dir()
1545
1876
  proxy_dir.mkdir(parents=True, exist_ok=True)
1546
1877
  state = {
@@ -43,6 +43,15 @@ class RuntimeClient:
43
43
  def logout(self) -> dict[str, Any]:
44
44
  return self._request("POST", "/api/auth/logout", auth_required=True)
45
45
 
46
+ def list_api_keys(self) -> dict[str, Any]:
47
+ return self._request("GET", "/api/api-keys", auth_required=True)
48
+
49
+ def create_api_key(self, label: str) -> dict[str, Any]:
50
+ return self._request("POST", "/api/api-keys", json={"label": label}, auth_required=True)
51
+
52
+ def revoke_api_key(self, key_id: int) -> dict[str, Any]:
53
+ return self._request("POST", f"/api/api-keys/{key_id}/revoke", auth_required=True)
54
+
46
55
  # --- Computers ---
47
56
 
48
57
  def create_computer(
@@ -82,6 +91,31 @@ class RuntimeClient:
82
91
  def get_computer_network(self, computer_id: str) -> dict[str, Any]:
83
92
  return self._request("GET", f"/api/computers/{computer_id}/network", auth_required=True)
84
93
 
94
+ def get_startup_config(self, computer_id: str) -> dict[str, Any]:
95
+ return self._request("GET", f"/api/computers/{computer_id}/startup", auth_required=True)
96
+
97
+ def get_service_config(self, computer_id: str) -> dict[str, Any]:
98
+ return self.get_startup_config(computer_id)
99
+
100
+ def set_startup_config(
101
+ self,
102
+ computer_id: str,
103
+ *,
104
+ command: str,
105
+ cwd: str | None = None,
106
+ port: int,
107
+ ) -> dict[str, Any]:
108
+ body: dict[str, Any] = {"command": command, "port": port}
109
+ if cwd is not None:
110
+ body["cwd"] = cwd
111
+ return self._request("PUT", f"/api/computers/{computer_id}/startup", json=body, auth_required=True)
112
+
113
+ def clear_startup_config(self, computer_id: str) -> dict[str, Any]:
114
+ return self._request("DELETE", f"/api/computers/{computer_id}/startup", auth_required=True)
115
+
116
+ def clear_service_config(self, computer_id: str) -> dict[str, Any]:
117
+ return self.clear_startup_config(computer_id)
118
+
85
119
  def start_computer(self, computer_id: str) -> dict[str, Any]:
86
120
  return self._request(
87
121
  "POST",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.10
3
+ Version: 0.4.12
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
@@ -52,18 +52,32 @@ runtime verify 123456
52
52
  runtime whoami
53
53
  runtime logout
54
54
  runtime login --api-key rt_live_...
55
+ runtime api-keys list
56
+ runtime api-keys create "my laptop"
57
+ runtime api-keys revoke 123
55
58
 
56
59
  # Computers
57
- runtime create # in a real TTY, creates then drops you into the console
60
+ runtime create # creates a computer with the starter app already published
58
61
  runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
59
62
  runtime enter <name-or-id> # accepts slug/name like test, or the computer id
60
63
  runtime list
61
64
  runtime info <id>
62
65
  runtime start <id>
63
- runtime run <id> "echo hello"
66
+ runtime run <id> "echo hello" # one-shot foreground command only
64
67
  runtime run <id> "apt install -y nodejs" --uid 0
65
- runtime publish <id> 3000
68
+ runtime startup show <id>
69
+ runtime startup set <id> --command "python3 app.py" --cwd /home/ubuntu --port 3000
70
+ runtime startup clear <id> # low-level durable service config
71
+ runtime service show <id> # user-facing alias for the durable published app
72
+ runtime service clear <id>
73
+ runtime publish <id> 3000 # promote the running app on port 3000 to the public durable app
66
74
  runtime delete <id>
75
+
76
+ # Inside a running computer, the helper installed by Runtime can manage the
77
+ # durable app without leaving the sandbox:
78
+ # runtime-env publish 3000
79
+ # runtime-env service show
80
+ # runtime-env service clear
67
81
  ```
68
82
 
69
83
  ## Python
@@ -73,12 +87,12 @@ from runtime_sdk import RuntimeClient
73
87
 
74
88
  client = RuntimeClient(base_url="https://api.runruntime.dev", api_key="rt_live_...")
75
89
 
76
- # Create a computer
90
+ # Create a computer. New computers start with the starter app already published.
77
91
  computer = client.create_computer()
78
92
  print(computer["public_url"]) # https://goldbird.runruntime.dev
79
93
 
80
- # Or create one with a durable startup command.
81
- # That startup config is replayed after cold restore / auto-wake.
94
+ # Or create one with an explicit durable app command.
95
+ # That saved service is replayed after cold restore / start.
82
96
  app = client.create_computer(
83
97
  slug="myapp",
84
98
  command="python3 app.py",
@@ -93,7 +107,8 @@ print(result["stdout"])
93
107
  # Wake a cold computer explicitly
94
108
  client.start_computer(app["id"])
95
109
 
96
- # Pin the public URL to a local app port
110
+ # Promote the running app on a local port to the durable public app.
111
+ # Runtime inspects the listening process and saves its command + cwd when possible.
97
112
  client.publish_port(computer["id"], 3000)
98
113
 
99
114
  # List, info, delete
@@ -120,10 +135,16 @@ make smoke
120
135
  Use `make deploy` instead of `make sync` when migrations, env
121
136
  files, Caddy, or systemd units changed.
122
137
 
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.
138
+ Cold restore and explicit `runtime start` replay the saved published app command.
139
+ New computers seed that durable app from the starter workspace. Later,
140
+ `runtime publish <id> <port>` can promote a running listener into the saved
141
+ public app definition by inspecting the live process. Inside a running computer,
142
+ `runtime-env publish <port>` does the same thing using a computer-scoped token
143
+ installed by Runtime. `runtime service show|clear` and `runtime-env service
144
+ show|clear` expose that same durable app state directly. The low-level
145
+ `runtime startup ...` commands still map to the same durable state. A one-off
146
+ `runtime run` stays one-shot: the filesystem is restored after going cold, but
147
+ that ad-hoc process is not.
127
148
 
128
149
  Run the SDK unit tests through the backend project environment:
129
150
 
File without changes