runtime-sdk 0.4.38__tar.gz → 0.4.39__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.38
3
+ Version: 0.4.39
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
@@ -79,6 +79,8 @@ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
79
79
  runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
80
80
  runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
81
81
  runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
82
+ runtime ssh <name-or-id> # open SSH through Runtime's authenticated tunnel
83
+ runtime ssh <name-or-id> -- uptime # run a command with the local ssh client
82
84
  runtime list
83
85
  runtime info <id>
84
86
  runtime share public <id>
@@ -196,6 +198,10 @@ runtime switch -c feature/search --repo owner/repo --from main
196
198
  Rules:
197
199
  - GitHub must already be connected with `runtime integrate github`.
198
200
  - Run `runtime integrate github` again when you want to add another personal account or org.
201
+ - New GitHub connects require user authorization during installation and
202
+ `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET` on the Runtime API.
203
+ In GitHub App settings, enable user authorization during installation and set
204
+ the callback URL to `https://api.runruntime.dev/github/callback`.
199
205
  - `runtime switch` opens an existing branch workspace, or creates the computer if
200
206
  the branch exists but the workspace does not yet.
201
207
  - `runtime switch -c` creates a new branch first, then opens that branch's
@@ -57,6 +57,8 @@ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
57
57
  runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
58
58
  runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
59
59
  runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
60
+ runtime ssh <name-or-id> # open SSH through Runtime's authenticated tunnel
61
+ runtime ssh <name-or-id> -- uptime # run a command with the local ssh client
60
62
  runtime list
61
63
  runtime info <id>
62
64
  runtime share public <id>
@@ -174,6 +176,10 @@ runtime switch -c feature/search --repo owner/repo --from main
174
176
  Rules:
175
177
  - GitHub must already be connected with `runtime integrate github`.
176
178
  - Run `runtime integrate github` again when you want to add another personal account or org.
179
+ - New GitHub connects require user authorization during installation and
180
+ `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET` on the Runtime API.
181
+ In GitHub App settings, enable user authorization during installation and set
182
+ the callback URL to `https://api.runruntime.dev/github/callback`.
177
183
  - `runtime switch` opens an existing branch workspace, or creates the computer if
178
184
  the branch exists but the workspace does not yet.
179
185
  - `runtime switch -c` creates a new branch first, then opens that branch's
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runtime-sdk"
3
- version = "0.4.38"
3
+ version = "0.4.39"
4
4
  description = "Runtime Python SDK and CLI"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,13 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import base64
4
5
  import contextlib
5
6
  import difflib
6
7
  import importlib
7
8
  import json
8
9
  import os
10
+ import pty
9
11
  import re
10
12
  import select
13
+ import secrets
11
14
  import shlex
12
15
  import shutil
13
16
  import signal
@@ -197,6 +200,25 @@ def build_parser() -> argparse.ArgumentParser:
197
200
  secrets_delete = secrets_subparsers.add_parser("delete", aliases=["rm"], help="Delete a secret set")
198
201
  secrets_delete.add_argument("slug", nargs="?", default=None, help="Secret set slug")
199
202
 
203
+ codex_cmd = subparsers.add_parser("codex", help="Log Codex into Runtime computers")
204
+ codex_subparsers = codex_cmd.add_subparsers(dest="codex_action", required=True)
205
+ codex_login = codex_subparsers.add_parser("login", help="Open Codex login and save it to Runtime")
206
+ codex_login.add_argument("--device", action="store_true", help="Use Codex device-code login")
207
+ codex_subparsers.add_parser("status", help="Show whether Runtime has Codex login")
208
+ codex_subparsers.add_parser("logout", help="Remove Runtime's saved Codex login")
209
+
210
+ claude_cmd = subparsers.add_parser("claude", help="Log Claude Code into Runtime computers")
211
+ claude_subparsers = claude_cmd.add_subparsers(dest="claude_action", required=True)
212
+ claude_subparsers.add_parser("login", help="Open Claude login and save it to Runtime")
213
+ claude_subparsers.add_parser("status", help="Show whether Runtime has Claude login")
214
+ claude_subparsers.add_parser("logout", help="Remove Runtime's saved Claude login")
215
+
216
+ xai_cmd = subparsers.add_parser("xai", help="Log xAI into Runtime computers")
217
+ xai_subparsers = xai_cmd.add_subparsers(dest="xai_action", required=True)
218
+ xai_subparsers.add_parser("login", help="Open xAI login and save it to Runtime")
219
+ xai_subparsers.add_parser("status", help="Show whether Runtime has xAI login")
220
+ xai_subparsers.add_parser("logout", help="Remove Runtime's saved xAI login")
221
+
200
222
  github_cmd = subparsers.add_parser("github", help="Manage connected GitHub App installations")
201
223
  github_subparsers = github_cmd.add_subparsers(dest="github_action", required=True)
202
224
  github_subparsers.add_parser("list", aliases=["ls"], help="List connected GitHub installations")
@@ -248,6 +270,14 @@ def build_parser() -> argparse.ArgumentParser:
248
270
  enter_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
249
271
  enter_cmd.add_argument("enter_command", nargs="*", help="Command to run in the interactive terminal")
250
272
 
273
+ ssh_cmd = subparsers.add_parser(
274
+ "ssh",
275
+ help="SSH into a computer through Runtime",
276
+ epilog="Extra arguments after the computer id are passed to ssh after the destination.",
277
+ )
278
+ ssh_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
279
+ ssh_cmd.add_argument("ssh_args", nargs=argparse.REMAINDER, help="Remote command or ssh arguments")
280
+
251
281
  info_cmd = subparsers.add_parser("info", help="Get computer details")
252
282
  info_cmd.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
253
283
 
@@ -374,6 +404,15 @@ def build_parser() -> argparse.ArgumentParser:
374
404
  service_clear = service_subparsers.add_parser("clear", help="Clear the durable published app service")
375
405
  service_clear.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
376
406
 
407
+ goal_cmd = subparsers.add_parser("goal", help="Watch email-triggered Codex goal runs")
408
+ goal_subparsers = goal_cmd.add_subparsers(dest="goal_action", required=True)
409
+ goal_watch = goal_subparsers.add_parser("watch", help="Stream a goal run from inside a computer")
410
+ goal_watch.add_argument("id", nargs="?", default=None, help="Computer ID (hostname)")
411
+ goal_watch.add_argument("--latest", action="store_true", help="Watch the latest known goal run")
412
+ goal_watch.add_argument("--next", action="store_true", help="Wait for and watch the next goal run")
413
+ goal_watch.add_argument("--email-id", help="Watch a specific Runtime email id, e.g. eml_...")
414
+ goal_watch.add_argument("--timeout", type=int, default=0, help="Stop after N seconds (default: no timeout)")
415
+
377
416
  proxy_cmd = subparsers.add_parser("proxy", help="Manage background local port proxies")
378
417
  proxy_subparsers = proxy_cmd.add_subparsers(dest="proxy_command", required=True)
379
418
  proxy_start = proxy_subparsers.add_parser("start", help="Start one or more background local port proxies")
@@ -416,6 +455,7 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
416
455
  ("url [id]", "Show a computer's public URL and live published port"),
417
456
  ("start [id]", "Wake a cold computer"),
418
457
  ("enter [id] [-- <cmd>]", "Enter a computer's console or interactive TTY command"),
458
+ ("ssh [id] [-- <cmd>]", "SSH into a computer through Runtime's authenticated tunnel"),
419
459
  ("run [id] <cmd>", "Run a one-shot command"),
420
460
  ("exec [id] -- <cmd>", "Stream stdout/stderr and optionally pipe stdin"),
421
461
  ("publish [id] <port> -- <cmd>", "Run and publish a durable service command"),
@@ -448,6 +488,13 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
448
488
  ("service clear [id]", "Clear the durable published app service"),
449
489
  ),
450
490
  ),
491
+ (
492
+ "Goals",
493
+ (
494
+ ("goal watch [id] --next", "Stream the next email-triggered Codex goal"),
495
+ ("goal watch [id] --latest", "Stream the latest email-triggered Codex goal"),
496
+ ),
497
+ ),
451
498
  (
452
499
  "Proxy",
453
500
  (
@@ -475,6 +522,9 @@ _HELP_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
475
522
  ("logout", "Revoke API key and remove local config"),
476
523
  ("api-keys list/create/revoke", "Manage Runtime API keys"),
477
524
  ("secrets list/show/set/import/rm", "Manage account-level secret sets"),
525
+ ("codex login/status/logout", "Log Codex into Runtime computers"),
526
+ ("claude login/status/logout", "Log Claude Code into Runtime computers"),
527
+ ("xai login/status/logout", "Log xAI into Runtime computers"),
478
528
  ),
479
529
  ),
480
530
  (
@@ -492,8 +542,12 @@ _HELP_EXAMPLES: tuple[str, ...] = (
492
542
  "runtime ls -w # live-updating list",
493
543
  "runtime create mybox --command 'npm start' --cwd /app --port 3000",
494
544
  "runtime share private mybox # require owner auth",
545
+ "runtime ssh mybox # SSH through Runtime without exposing VM IPs",
495
546
  "runtime integrate github # open GitHub and wait for the install to finish",
496
547
  "runtime github list # show connected GitHub orgs/accounts",
548
+ "runtime codex login # open Codex login and save it to Runtime",
549
+ "runtime claude login # open Claude login and save it to Runtime",
550
+ "runtime xai login # open xAI login through Slicer and save it to Runtime",
497
551
  "runtime proxy start mybox 5432 # forward localhost:5432 → mybox:5432",
498
552
  "runtime completion zsh > ~/.zsh/completions/_runtime",
499
553
  )
@@ -601,6 +655,30 @@ def main(argv: list[str] | None = None) -> int:
601
655
  if args.secrets_action in ("delete", "rm"):
602
656
  return handle_secrets_delete(config, args.slug)
603
657
  return report_error("unknown secrets command")
658
+ if args.command == "codex":
659
+ if args.codex_action == "login":
660
+ return handle_codex_login(config, device=args.device)
661
+ if args.codex_action == "status":
662
+ return handle_codex_status(config)
663
+ if args.codex_action == "logout":
664
+ return handle_codex_logout(config)
665
+ return report_error("unknown codex command")
666
+ if args.command == "claude":
667
+ if args.claude_action == "login":
668
+ return handle_claude_login(config)
669
+ if args.claude_action == "status":
670
+ return handle_claude_status(config)
671
+ if args.claude_action == "logout":
672
+ return handle_claude_logout(config)
673
+ return report_error("unknown claude command")
674
+ if args.command == "xai":
675
+ if args.xai_action == "login":
676
+ return handle_xai_login(config)
677
+ if args.xai_action == "status":
678
+ return handle_xai_status(config)
679
+ if args.xai_action == "logout":
680
+ return handle_xai_logout(config)
681
+ return report_error("unknown xai command")
604
682
  if args.command == "github":
605
683
  if args.github_action in ("list", "ls"):
606
684
  return handle_github_list(config)
@@ -637,6 +715,8 @@ def main(argv: list[str] | None = None) -> int:
637
715
  return handle_start(config, args.id)
638
716
  if args.command == "enter":
639
717
  return handle_enter(config, enter_id, enter_command)
718
+ if args.command == "ssh":
719
+ return handle_ssh(config, args.id, args.ssh_args)
640
720
  if args.command in ("delete", "rm"):
641
721
  return handle_delete(config, args.id, force=getattr(args, "force", False))
642
722
  if args.command == "run":
@@ -671,6 +751,10 @@ def main(argv: list[str] | None = None) -> int:
671
751
  if args.service_action == "clear":
672
752
  return handle_service_clear(config, args.id)
673
753
  return report_error("unknown service command")
754
+ if args.command == "goal":
755
+ if args.goal_action == "watch":
756
+ return handle_goal_watch(config, args.id, latest=args.latest, next_goal=args.next, email_id=args.email_id, timeout=args.timeout)
757
+ return report_error("unknown goal command")
674
758
  if args.command == "proxy":
675
759
  if args.proxy_command == "start":
676
760
  return handle_proxy_start(config, args.id, args.ports)
@@ -1634,6 +1718,17 @@ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
1634
1718
  _UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
1635
1719
 
1636
1720
 
1721
+ def _render_provider_login_status(payload: dict[str, Any]) -> None:
1722
+ provider = payload.get("provider") or "provider"
1723
+ if payload.get("logged_in"):
1724
+ credential = payload.get("provider_credential") or {}
1725
+ updated = _relative_time_label(credential.get("updated_at")) if isinstance(credential, dict) else None
1726
+ suffix = f" [dim]updated {updated}[/dim]" if updated else ""
1727
+ _UI.console().print(f"[green]✓[/green] {provider} login is saved in Runtime{suffix}")
1728
+ return
1729
+ _UI.console().print(f"[yellow]{provider} is not logged into Runtime[/yellow]")
1730
+
1731
+
1637
1732
  # --------------------------------------------------------------------------- #
1638
1733
  # Command handlers #
1639
1734
  # --------------------------------------------------------------------------- #
@@ -2052,6 +2147,305 @@ def handle_secrets_delete(config: RuntimeConfig, slug: str | None) -> int:
2052
2147
  )
2053
2148
 
2054
2149
 
2150
+ def _read_provider_auth_file(path: str | Path) -> str:
2151
+ try:
2152
+ return Path(path).read_text(encoding="utf-8")
2153
+ except OSError as exc:
2154
+ raise RuntimeConfigError(f"failed to read auth file: {exc}") from exc
2155
+
2156
+
2157
+ def _codex_auth_path(codex_home: Path) -> Path:
2158
+ return codex_home / "auth.json"
2159
+
2160
+
2161
+ def _claude_auth_env(home: Path) -> dict[str, str]:
2162
+ env = os.environ.copy()
2163
+ env["HOME"] = str(home)
2164
+ env["XDG_CONFIG_HOME"] = str(home / ".config")
2165
+ env.pop("ANTHROPIC_API_KEY", None)
2166
+ env.pop("ANTHROPIC_AUTH_TOKEN", None)
2167
+ env.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
2168
+ return env
2169
+
2170
+
2171
+ def _run_codex_login(codex_bin: str, codex_home: Path, *, device: bool) -> int:
2172
+ cmd = [
2173
+ codex_bin,
2174
+ "login",
2175
+ "-c",
2176
+ 'cli_auth_credentials_store="file"',
2177
+ ]
2178
+ if device:
2179
+ cmd.append("--device-auth")
2180
+ env = os.environ.copy()
2181
+ env["CODEX_HOME"] = str(codex_home)
2182
+ return subprocess.run(cmd, env=env).returncode
2183
+
2184
+
2185
+ def _run_claude_setup_token(claude_bin: str, home: Path, *, timeout_seconds: int = 300) -> str:
2186
+ sys.stderr.write("Creating Claude Runtime proxy token. Complete the browser flow if prompted.\n")
2187
+ sys.stderr.flush()
2188
+ master_fd, slave_fd = pty.openpty()
2189
+ proc = subprocess.Popen(
2190
+ [claude_bin, "setup-token"],
2191
+ stdin=slave_fd,
2192
+ stdout=slave_fd,
2193
+ stderr=slave_fd,
2194
+ env=_claude_auth_env(home),
2195
+ close_fds=True,
2196
+ )
2197
+ os.close(slave_fd)
2198
+ chunks: list[bytes] = []
2199
+ displayed = 0
2200
+ token_output_hidden = False
2201
+ deadline = time.monotonic() + timeout_seconds
2202
+ try:
2203
+ while proc.poll() is None:
2204
+ remaining = deadline - time.monotonic()
2205
+ if remaining <= 0:
2206
+ proc.terminate()
2207
+ try:
2208
+ proc.wait(timeout=5)
2209
+ except subprocess.TimeoutExpired:
2210
+ proc.kill()
2211
+ proc.wait()
2212
+ raise RuntimeConfigError("claude setup-token timed out")
2213
+ readers = [master_fd]
2214
+ if _interactive():
2215
+ readers.append(sys.stdin.fileno())
2216
+ readable, _, _ = select.select(readers, [], [], min(0.2, remaining))
2217
+ for fd in readable:
2218
+ if fd == master_fd:
2219
+ try:
2220
+ data = os.read(master_fd, 4096)
2221
+ except OSError:
2222
+ data = b""
2223
+ if data:
2224
+ chunks.append(data)
2225
+ displayed, token_output_hidden = _write_claude_setup_output(
2226
+ b"".join(chunks).decode("utf-8", errors="ignore"),
2227
+ displayed,
2228
+ token_output_hidden,
2229
+ )
2230
+ else:
2231
+ data = os.read(fd, 1024)
2232
+ if data:
2233
+ os.write(master_fd, data)
2234
+ while True:
2235
+ try:
2236
+ data = os.read(master_fd, 4096)
2237
+ except OSError:
2238
+ break
2239
+ if not data:
2240
+ break
2241
+ chunks.append(data)
2242
+ displayed, token_output_hidden = _write_claude_setup_output(
2243
+ b"".join(chunks).decode("utf-8", errors="ignore"),
2244
+ displayed,
2245
+ token_output_hidden,
2246
+ )
2247
+ finally:
2248
+ with contextlib.suppress(OSError):
2249
+ os.close(master_fd)
2250
+ status = proc.wait()
2251
+ if status != 0:
2252
+ raise RuntimeConfigError(f"claude setup-token failed with exit code {status}")
2253
+ token = _extract_claude_oauth_token(b"".join(chunks).decode("utf-8", errors="ignore"))
2254
+ if not token:
2255
+ raise RuntimeConfigError("claude setup-token completed but did not print an OAuth token")
2256
+ return token
2257
+
2258
+
2259
+ def _write_claude_setup_output(output: str, displayed: int, token_output_hidden: bool) -> tuple[int, bool]:
2260
+ marker = re.search(r"Your OAuth token", output, flags=re.I)
2261
+ if marker:
2262
+ if marker.start() > displayed:
2263
+ sys.stderr.write(output[displayed:marker.start()])
2264
+ sys.stderr.flush()
2265
+ if not token_output_hidden:
2266
+ sys.stderr.write("\nClaude OAuth token captured and hidden.\n")
2267
+ sys.stderr.flush()
2268
+ return len(output), True
2269
+ if len(output) > displayed:
2270
+ chunk = re.sub(r"sk-ant-[A-Za-z0-9_-]{12,}", "[hidden]", output[displayed:])
2271
+ sys.stderr.write(chunk)
2272
+ sys.stderr.flush()
2273
+ return len(output), token_output_hidden
2274
+
2275
+
2276
+ def _extract_claude_oauth_token(output: str) -> str:
2277
+ output = re.sub(r"\x1b\].*?(?:\x07|\x1b\\)", "", output, flags=re.S)
2278
+ output = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", output)
2279
+ section = output
2280
+ marker = re.search(r"Your OAuth token.*?:", output, flags=re.I | re.S)
2281
+ if marker:
2282
+ section = output[marker.end():]
2283
+ section = re.split(r"Store this token", section, maxsplit=1, flags=re.I)[0]
2284
+ compact = re.sub(r"\s+", "", section)
2285
+ match = re.search(r"sk-ant-[A-Za-z0-9_-]{20,}", compact)
2286
+ return match.group(0) if match else ""
2287
+
2288
+
2289
+ def _claude_oauth_token_json(token: str) -> str:
2290
+ return json.dumps({"type": "claude-code-oauth-token", "token": token}, separators=(",", ":"))
2291
+
2292
+
2293
+ def _run_xai_oauth(slicer_bin: str) -> str:
2294
+ sys.stderr.write("Opening xAI OAuth flow through Slicer. Complete the browser flow if prompted.\n")
2295
+ sys.stderr.flush()
2296
+ proc = subprocess.run([slicer_bin, "proxy", "oauth", "xai"], stdout=subprocess.PIPE, text=True)
2297
+ if proc.returncode != 0:
2298
+ raise RuntimeConfigError(f"slicer xAI OAuth failed with exit code {proc.returncode}")
2299
+ auth_json = proc.stdout.strip()
2300
+ if not auth_json:
2301
+ raise RuntimeConfigError("slicer xAI OAuth completed but did not emit auth JSON")
2302
+ try:
2303
+ decoded = json.loads(auth_json)
2304
+ except json.JSONDecodeError as exc:
2305
+ raise RuntimeConfigError("slicer xAI OAuth emitted invalid JSON") from exc
2306
+ if not isinstance(decoded, dict):
2307
+ raise RuntimeConfigError("slicer xAI OAuth emitted non-object JSON")
2308
+ return auth_json
2309
+
2310
+
2311
+ def _require_xai_oauth_helper(slicer_bin: str) -> None:
2312
+ proc = subprocess.run(
2313
+ [slicer_bin, "proxy", "oauth", "xai", "--help"],
2314
+ stdout=subprocess.PIPE,
2315
+ stderr=subprocess.PIPE,
2316
+ text=True,
2317
+ )
2318
+ if proc.returncode == 0:
2319
+ return
2320
+ detail = (proc.stderr or proc.stdout or "").strip()
2321
+ if detail:
2322
+ detail = ": " + detail.splitlines()[-1]
2323
+ raise RuntimeConfigError("slicer CLI does not support `slicer proxy oauth xai`; update Slicer" + detail)
2324
+
2325
+
2326
+ def _find_provider(credentials: dict[str, Any], provider: str) -> dict[str, Any] | None:
2327
+ for credential in credentials.get("provider_credentials") or []:
2328
+ if credential.get("provider") == provider:
2329
+ return credential
2330
+ return None
2331
+
2332
+
2333
+ def _provider_label(provider: str) -> str:
2334
+ return {"claude": "Claude", "codex": "Codex", "xai": "xAI"}.get(provider, provider)
2335
+
2336
+
2337
+ def handle_provider_status(config: RuntimeConfig, provider: str) -> int:
2338
+ if (err := _require_api_key(config)) is not None:
2339
+ return err
2340
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2341
+ credential = _find_provider(client.list_provider_credentials(), provider)
2342
+ return report_success(
2343
+ {"provider": provider, "logged_in": credential is not None, "provider_credential": credential},
2344
+ _render_provider_login_status,
2345
+ )
2346
+
2347
+
2348
+ def handle_provider_logout(config: RuntimeConfig, provider: str) -> int:
2349
+ if (err := _require_api_key(config)) is not None:
2350
+ return err
2351
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2352
+ try:
2353
+ client.delete_provider_credential(provider)
2354
+ except RuntimeAPIError as exc:
2355
+ if exc.status_code != 404:
2356
+ raise
2357
+ label = _provider_label(provider)
2358
+ return report_success(
2359
+ {"provider": provider, "logged_in": False},
2360
+ lambda payload: _UI.console().print(f"[green]✓[/green] {label} login removed from Runtime"),
2361
+ )
2362
+
2363
+
2364
+ def handle_codex_status(config: RuntimeConfig) -> int:
2365
+ return handle_provider_status(config, "codex")
2366
+
2367
+
2368
+ def handle_codex_login(config: RuntimeConfig, *, device: bool = False) -> int:
2369
+ if (err := _require_api_key(config)) is not None:
2370
+ return err
2371
+ codex_bin = shutil.which("codex")
2372
+ if not codex_bin:
2373
+ return report_error("codex CLI is not installed")
2374
+
2375
+ with tempfile.TemporaryDirectory(prefix="runtime-codex-") as codex_home:
2376
+ home = Path(codex_home)
2377
+ status = _run_codex_login(codex_bin, home, device=device)
2378
+ if status != 0:
2379
+ return report_error(f"codex login failed with exit code {status}")
2380
+ auth_path = _codex_auth_path(home)
2381
+ if not auth_path.exists():
2382
+ return report_error("codex login completed but did not write auth.json")
2383
+ auth_json = _read_provider_auth_file(auth_path)
2384
+
2385
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2386
+ credential = client.put_provider_credential("codex", auth_json)
2387
+ return report_success(
2388
+ {"provider": "codex", "logged_in": True, "provider_credential": credential},
2389
+ lambda payload: _UI.console().print("[green]✓[/green] Codex login saved to Runtime"),
2390
+ )
2391
+
2392
+
2393
+ def handle_codex_logout(config: RuntimeConfig) -> int:
2394
+ return handle_provider_logout(config, "codex")
2395
+
2396
+
2397
+ def handle_claude_status(config: RuntimeConfig) -> int:
2398
+ return handle_provider_status(config, "claude")
2399
+
2400
+
2401
+ def handle_claude_login(config: RuntimeConfig) -> int:
2402
+ if (err := _require_api_key(config)) is not None:
2403
+ return err
2404
+ claude_bin = shutil.which("claude")
2405
+ if not claude_bin:
2406
+ return report_error("claude CLI is not installed")
2407
+
2408
+ with tempfile.TemporaryDirectory(prefix="runtime-claude-") as claude_home:
2409
+ home = Path(claude_home)
2410
+ token = _run_claude_setup_token(claude_bin, home)
2411
+ auth_json = _claude_oauth_token_json(token)
2412
+
2413
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2414
+ credential = client.put_provider_credential("claude", auth_json)
2415
+ return report_success(
2416
+ {"provider": "claude", "logged_in": True, "provider_credential": credential},
2417
+ lambda payload: _UI.console().print("[green]✓[/green] Claude login saved to Runtime"),
2418
+ )
2419
+
2420
+
2421
+ def handle_claude_logout(config: RuntimeConfig) -> int:
2422
+ return handle_provider_logout(config, "claude")
2423
+
2424
+
2425
+ def handle_xai_status(config: RuntimeConfig) -> int:
2426
+ return handle_provider_status(config, "xai")
2427
+
2428
+
2429
+ def handle_xai_login(config: RuntimeConfig) -> int:
2430
+ if (err := _require_api_key(config)) is not None:
2431
+ return err
2432
+ slicer_bin = shutil.which("slicer")
2433
+ if not slicer_bin:
2434
+ return report_error("slicer CLI is not installed")
2435
+
2436
+ _require_xai_oauth_helper(slicer_bin)
2437
+ auth_json = _run_xai_oauth(slicer_bin)
2438
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
2439
+ credential = client.put_provider_credential("xai", auth_json)
2440
+ return report_success(
2441
+ {"provider": "xai", "logged_in": True, "provider_credential": credential},
2442
+ lambda payload: _UI.console().print("[green]✓[/green] xAI login saved to Runtime"),
2443
+ )
2444
+
2445
+
2446
+ def handle_xai_logout(config: RuntimeConfig) -> int:
2447
+ return handle_provider_logout(config, "xai")
2448
+
2055
2449
 
2056
2450
  def handle_github_list(config: RuntimeConfig) -> int:
2057
2451
  if (err := _require_api_key(config)) is not None:
@@ -2574,6 +2968,300 @@ def handle_service_logs(config: RuntimeConfig, computer_id: str | None, follow:
2574
2968
  return 0
2575
2969
 
2576
2970
 
2971
+ GOAL_WATCH_DIR = "/home/ubuntu/.runtime/email-goals"
2972
+
2973
+
2974
+ def _goal_emit(tag: str, message: Any) -> None:
2975
+ text = str(message or "").rstrip()
2976
+ if not text:
2977
+ return
2978
+ lines = text.splitlines() or [text]
2979
+ for index, line in enumerate(lines):
2980
+ prefix = f"[{tag}] " if index == 0 else " " * (len(tag) + 3)
2981
+ print(prefix + line, flush=True)
2982
+
2983
+
2984
+ def _goal_parse_time(value: Any) -> float:
2985
+ if not value:
2986
+ return 0.0
2987
+ text = str(value)
2988
+ match = re.match(r"^(.*T\d\d:\d\d:\d\d)(?:\.(\d+))?(Z|[+-]\d\d:\d\d)?$", text)
2989
+ if match:
2990
+ fraction = match.group(2) or ""
2991
+ suffix = match.group(3) or ""
2992
+ text = match.group(1)
2993
+ if fraction:
2994
+ text += "." + fraction[:6].ljust(6, "0")
2995
+ text += suffix
2996
+ try:
2997
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
2998
+ except ValueError:
2999
+ return 0.0
3000
+
3001
+
3002
+ def _goal_read_text(client: RuntimeClient, computer_id: str, path: str) -> str | None:
3003
+ try:
3004
+ return client.read_file(computer_id, path).decode("utf-8", errors="replace")
3005
+ except RuntimeAPIError as exc:
3006
+ if exc.status_code == 404:
3007
+ return None
3008
+ raise
3009
+
3010
+
3011
+ def _goal_read_json(client: RuntimeClient, computer_id: str, path: str) -> dict[str, Any] | None:
3012
+ text = _goal_read_text(client, computer_id, path)
3013
+ if not text:
3014
+ return None
3015
+ try:
3016
+ payload = json.loads(text)
3017
+ except ValueError:
3018
+ return None
3019
+ return payload if isinstance(payload, dict) else None
3020
+
3021
+
3022
+ def _goal_list_logs(client: RuntimeClient, computer_id: str) -> list[dict[str, Any]]:
3023
+ try:
3024
+ payload = client.list_files(computer_id, GOAL_WATCH_DIR)
3025
+ except RuntimeAPIError as exc:
3026
+ if exc.status_code == 404:
3027
+ return []
3028
+ raise
3029
+ entries = payload.get("entries") or []
3030
+ logs = [
3031
+ entry for entry in entries
3032
+ if isinstance(entry, dict) and str(entry.get("name") or "").endswith(".log")
3033
+ ]
3034
+ return sorted(logs, key=lambda entry: _goal_parse_time(entry.get("mtime")))
3035
+
3036
+
3037
+ def _goal_run_json(client: RuntimeClient, computer_id: str, command: str) -> dict[str, Any] | None:
3038
+ try:
3039
+ result = client.run_command(computer_id, command, uid=1000, gid=1000, cwd="/home/ubuntu", shell="/bin/bash")
3040
+ except RuntimeAPIError:
3041
+ return None
3042
+ if int(result.get("exit_code") or 0) != 0:
3043
+ return None
3044
+ try:
3045
+ payload = json.loads(result.get("stdout") or "{}")
3046
+ except ValueError:
3047
+ return None
3048
+ return payload if isinstance(payload, dict) else None
3049
+
3050
+
3051
+ def _goal_run_text(client: RuntimeClient, computer_id: str, command: str) -> str:
3052
+ try:
3053
+ result = client.run_command(computer_id, command, uid=1000, gid=1000, cwd="/home/ubuntu", shell="/bin/bash")
3054
+ except RuntimeAPIError:
3055
+ return ""
3056
+ if int(result.get("exit_code") or 0) != 0:
3057
+ return ""
3058
+ return str(result.get("stdout") or "").strip()
3059
+
3060
+
3061
+ def _goal_current_email_ids(client: RuntimeClient, computer_id: str) -> set[str]:
3062
+ payload = _goal_run_json(client, computer_id, "runtime-env email inbox --json")
3063
+ emails = payload.get("emails") if isinstance(payload, dict) else []
3064
+ return {str(item.get("id") or "") for item in emails or [] if isinstance(item, dict) and item.get("id")}
3065
+
3066
+
3067
+ def _goal_render_log_line(line: str, state: dict[str, Any]) -> None:
3068
+ text = str(line or "").strip()
3069
+ if not text:
3070
+ return
3071
+ mappings = (
3072
+ ("assistant final_answer: ", "agent-final"),
3073
+ ("assistant commentary: ", "agent"),
3074
+ ("assistant analysis: ", "agent"),
3075
+ ("assistant message: ", "agent"),
3076
+ ("assistant delta: ", "agent"),
3077
+ ("started command: ", "cmd"),
3078
+ ("command: ", "cmd"),
3079
+ ("command output delta: ", "out"),
3080
+ ("command output: ", "out"),
3081
+ ("file change: ", "file"),
3082
+ ("mcp tool: ", "mcp"),
3083
+ ("token usage: ", "usage"),
3084
+ ("codex turn started", "codex"),
3085
+ ("codex turn completed: ", "codex"),
3086
+ )
3087
+ for prefix, tag in mappings:
3088
+ if text.startswith(prefix):
3089
+ state["native_codex_stream_seen"] = True
3090
+ value = text[len(prefix):] if text != prefix.strip() else text
3091
+ if tag == "cmd" and value.startswith("command: "):
3092
+ value = value[len("command: "):]
3093
+ _goal_emit(tag, value)
3094
+ return
3095
+ _goal_emit("goal", text)
3096
+
3097
+
3098
+ def _goal_wait_for_target(
3099
+ client: RuntimeClient,
3100
+ computer_id: str,
3101
+ *,
3102
+ mode: str,
3103
+ email_id: str | None,
3104
+ baseline: set[str],
3105
+ deadline: float | None,
3106
+ ) -> str:
3107
+ if email_id:
3108
+ _goal_emit("watch", f"waiting for {email_id}")
3109
+ while True:
3110
+ logs = _goal_list_logs(client, computer_id)
3111
+ if any(entry.get("name") == f"{email_id}.log" for entry in logs):
3112
+ return email_id
3113
+ _goal_check_timeout(deadline)
3114
+ time.sleep(0.75)
3115
+
3116
+ _goal_emit("watch", "waiting for next email goal" if mode == "next" else "waiting for latest email goal")
3117
+ while True:
3118
+ logs = _goal_list_logs(client, computer_id)
3119
+ if mode == "next":
3120
+ logs = [entry for entry in logs if str(entry.get("name") or "") not in baseline]
3121
+ if logs:
3122
+ return str(logs[-1].get("name") or "").removesuffix(".log")
3123
+ _goal_check_timeout(deadline)
3124
+ time.sleep(0.75)
3125
+
3126
+
3127
+ def _goal_check_timeout(deadline: float | None) -> None:
3128
+ if deadline is not None and time.time() >= deadline:
3129
+ _goal_emit("watch", "timeout reached")
3130
+ raise TimeoutError
3131
+
3132
+
3133
+ def _goal_poll_mail(client: RuntimeClient, computer_id: str, seen: set[str], target_email_id: str, started_at: float) -> None:
3134
+ payload = _goal_run_json(client, computer_id, "runtime-env email inbox --json")
3135
+ emails = payload.get("emails") if isinstance(payload, dict) else []
3136
+ for item in sorted((email for email in emails or [] if isinstance(email, dict)), key=lambda email: _goal_parse_time(email.get("created_at"))):
3137
+ item_id = str(item.get("id") or "")
3138
+ if not item_id or item_id in seen:
3139
+ continue
3140
+ created = _goal_parse_time(item.get("created_at"))
3141
+ if item_id != target_email_id and created and created < started_at - 5:
3142
+ seen.add(item_id)
3143
+ continue
3144
+ seen.add(item_id)
3145
+ direction = item.get("direction") or "email"
3146
+ status = item.get("status") or ""
3147
+ subject = item.get("subject") or ""
3148
+ _goal_emit("mail", f"{direction} {status} {item_id}: {subject}".strip())
3149
+
3150
+
3151
+ def _goal_poll_service(client: RuntimeClient, computer_id: str, last_status: str) -> str:
3152
+ status = _goal_run_text(client, computer_id, "runtime-env service status")
3153
+ if status and status != last_status:
3154
+ _goal_emit("service", status)
3155
+ return status
3156
+ return last_status
3157
+
3158
+
3159
+ def _goal_describe(client: RuntimeClient, computer_id: str, email_id: str) -> None:
3160
+ metadata = _goal_read_json(client, computer_id, f"{GOAL_WATCH_DIR}/{email_id}.json")
3161
+ if not metadata:
3162
+ return
3163
+ sender = metadata.get("from") or ""
3164
+ to = ", ".join(metadata.get("to") or [])
3165
+ subject = metadata.get("subject") or ""
3166
+ _goal_emit("mail", f"received {email_id} from {sender} to {to}: {subject}")
3167
+
3168
+
3169
+ def _goal_watch_polling(client: RuntimeClient, computer_id: str, *, mode: str, email_id: str | None, timeout: int) -> int:
3170
+ started_at = time.time()
3171
+ deadline = started_at + timeout if timeout > 0 else None
3172
+ baseline_logs = {str(entry.get("name") or "") for entry in _goal_list_logs(client, computer_id)} if mode == "next" else set()
3173
+ seen_emails = _goal_current_email_ids(client, computer_id) if mode == "next" else set()
3174
+ state: dict[str, Any] = {"native_codex_stream_seen": False}
3175
+
3176
+ try:
3177
+ target_email_id = _goal_wait_for_target(
3178
+ client,
3179
+ computer_id,
3180
+ mode=mode,
3181
+ email_id=email_id,
3182
+ baseline=baseline_logs,
3183
+ deadline=deadline,
3184
+ )
3185
+ except TimeoutError:
3186
+ return 124
3187
+
3188
+ target_log = f"{GOAL_WATCH_DIR}/{target_email_id}.log"
3189
+ target_response = f"{GOAL_WATCH_DIR}/{target_email_id}.response.md"
3190
+ target_started_at = time.time()
3191
+ offset = 0
3192
+ response_printed = False
3193
+ done_seen_at: float | None = None
3194
+ last_mail_poll = 0.0
3195
+ last_service_poll = 0.0
3196
+ last_service_status = ""
3197
+
3198
+ _goal_emit("goal", f"watching {target_email_id}")
3199
+ _goal_describe(client, computer_id, target_email_id)
3200
+
3201
+ while True:
3202
+ try:
3203
+ _goal_check_timeout(deadline)
3204
+ except TimeoutError:
3205
+ return 124
3206
+ now = time.time()
3207
+
3208
+ text = _goal_read_text(client, computer_id, target_log) or ""
3209
+ if len(text) < offset:
3210
+ offset = 0
3211
+ if len(text) > offset:
3212
+ chunk = text[offset:]
3213
+ offset = len(text)
3214
+ for line in chunk.splitlines():
3215
+ _goal_render_log_line(line, state)
3216
+
3217
+ if now - last_mail_poll >= 2:
3218
+ _goal_poll_mail(client, computer_id, seen_emails, target_email_id, target_started_at)
3219
+ last_mail_poll = now
3220
+
3221
+ if now - last_service_poll >= 2:
3222
+ last_service_status = _goal_poll_service(client, computer_id, last_service_status)
3223
+ last_service_poll = now
3224
+
3225
+ if not response_printed:
3226
+ response = (_goal_read_text(client, computer_id, target_response) or "").strip()
3227
+ if response:
3228
+ _goal_emit("done", response)
3229
+ response_printed = True
3230
+ done_seen_at = now
3231
+
3232
+ if done_seen_at is not None and now - done_seen_at >= 3:
3233
+ return 0
3234
+
3235
+ time.sleep(0.75)
3236
+
3237
+
3238
+ def handle_goal_watch(
3239
+ config: RuntimeConfig,
3240
+ computer_id: str | None,
3241
+ *,
3242
+ latest: bool,
3243
+ next_goal: bool,
3244
+ email_id: str | None,
3245
+ timeout: int,
3246
+ ) -> int:
3247
+ if (err := _require_api_key(config)) is not None:
3248
+ return err
3249
+ if not computer_id:
3250
+ return report_error("computer id is required")
3251
+ if latest and next_goal:
3252
+ return report_error("--latest and --next cannot be used together")
3253
+ if email_id and next_goal:
3254
+ return report_error("--email-id and --next cannot be used together")
3255
+ if timeout < 0:
3256
+ return report_error("--timeout must be non-negative")
3257
+
3258
+ mode = "email" if email_id else "next" if next_goal else "latest"
3259
+
3260
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
3261
+ computer_id = _resolve_computer_ref(client, computer_id)
3262
+ return _goal_watch_polling(client, computer_id, mode=mode, email_id=email_id, timeout=timeout)
3263
+
3264
+
2577
3265
  def handle_service_restart(config: RuntimeConfig, computer_id: str | None) -> int:
2578
3266
  if (err := _require_api_key(config)) is not None:
2579
3267
  return err
@@ -2673,6 +3361,213 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None, command_parts:
2673
3361
  return _enter_computer(config, str(computer_id))
2674
3362
 
2675
3363
 
3364
+ def handle_ssh(config: RuntimeConfig, computer_id: str | None, ssh_args: list[str] | None = None) -> int:
3365
+ if (err := _require_api_key(config)) is not None:
3366
+ return err
3367
+ ssh_bin = shutil.which("ssh")
3368
+ if not ssh_bin:
3369
+ return report_error("ssh is not installed")
3370
+ ssh_keygen_bin = shutil.which("ssh-keygen")
3371
+ if not ssh_keygen_bin:
3372
+ return report_error("ssh-keygen is not installed")
3373
+
3374
+ if computer_id is None:
3375
+ if not _interactive():
3376
+ return report_error("computer id is required")
3377
+ picked = _pick_computer(
3378
+ config,
3379
+ "Pick a computer to ssh",
3380
+ lambda c: _computer_internal_status(c) in ("running", "cold"),
3381
+ )
3382
+ if picked is None:
3383
+ return 0
3384
+ computer_id = picked.get("id") or picked.get("slug")
3385
+ if not computer_id:
3386
+ return report_error("computer is missing an id")
3387
+
3388
+ client = RuntimeClient(base_url=config.base_url, api_key=config.api_key)
3389
+ resolved_id = _resolve_computer_ref(client, str(computer_id))
3390
+ args = _ssh_extra_args(ssh_args or [])
3391
+
3392
+ with tempfile.TemporaryDirectory(prefix="runtime-ssh-") as temp_dir:
3393
+ temp = Path(temp_dir)
3394
+ key_path = temp / "id_ed25519"
3395
+ known_hosts = temp / "known_hosts"
3396
+ marker = "runtimevm-ssh-" + secrets.token_hex(8)
3397
+ if (key_status := _generate_ssh_key(ssh_keygen_bin, key_path)) != 0:
3398
+ return report_error(f"ssh-keygen failed with exit code {key_status}")
3399
+ public_key = key_path.with_suffix(key_path.suffix + ".pub").read_text(encoding="utf-8").strip()
3400
+
3401
+ _with_spinner("preparing computer for ssh…", lambda: client.create_terminal_ticket(resolved_id))
3402
+ cleanup_proxy: Callable[[], None] | None = None
3403
+ try:
3404
+ _install_temporary_ssh_key(client, resolved_id, public_key, marker)
3405
+ local_port, cleanup_proxy = _start_temporary_proxy(config.base_url, str(config.api_key or ""), resolved_id, 22)
3406
+ ssh_cmd = [
3407
+ ssh_bin,
3408
+ "-i",
3409
+ str(key_path),
3410
+ "-p",
3411
+ str(local_port),
3412
+ "-o",
3413
+ "IdentitiesOnly=yes",
3414
+ "-o",
3415
+ f"UserKnownHostsFile={known_hosts}",
3416
+ "-o",
3417
+ "StrictHostKeyChecking=accept-new",
3418
+ "-o",
3419
+ "LogLevel=ERROR",
3420
+ "ubuntu@127.0.0.1",
3421
+ ]
3422
+ ssh_cmd.extend(args)
3423
+ return subprocess.run(ssh_cmd).returncode
3424
+ finally:
3425
+ if cleanup_proxy is not None:
3426
+ cleanup_proxy()
3427
+ _remove_temporary_ssh_key(client, resolved_id, marker)
3428
+
3429
+
3430
+ def _ssh_extra_args(args: list[str]) -> list[str]:
3431
+ if args and args[0] == "--":
3432
+ return args[1:]
3433
+ return args
3434
+
3435
+
3436
+ def _generate_ssh_key(ssh_keygen_bin: str, key_path: Path) -> int:
3437
+ return subprocess.run([
3438
+ ssh_keygen_bin,
3439
+ "-q",
3440
+ "-t",
3441
+ "ed25519",
3442
+ "-N",
3443
+ "",
3444
+ "-C",
3445
+ "runtimevm-ephemeral",
3446
+ "-f",
3447
+ str(key_path),
3448
+ ]).returncode
3449
+
3450
+
3451
+ def _install_temporary_ssh_key(client: RuntimeClient, computer_id: str, public_key: str, marker: str) -> None:
3452
+ encoded_key = base64.b64encode(public_key.encode()).decode()
3453
+ script = f"""set -euo pipefail
3454
+ if ! command -v sshd >/dev/null 2>&1 && [ ! -x /usr/sbin/sshd ]; then
3455
+ echo "openssh-server is not installed in this VM image" >&2
3456
+ exit 127
3457
+ fi
3458
+ mkdir -p /run/sshd
3459
+ if command -v systemctl >/dev/null 2>&1; then
3460
+ systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || /usr/sbin/sshd
3461
+ else
3462
+ /usr/sbin/sshd
3463
+ fi
3464
+ install -d -m 700 -o ubuntu -g ubuntu /home/ubuntu/.ssh
3465
+ auth=/home/ubuntu/.ssh/authorized_keys
3466
+ touch "$auth"
3467
+ chown ubuntu:ubuntu "$auth"
3468
+ chmod 600 "$auth"
3469
+ tmp="$(mktemp)"
3470
+ grep -Fv -- {shlex.quote(marker)} "$auth" > "$tmp" || true
3471
+ cat "$tmp" > "$auth"
3472
+ rm -f "$tmp"
3473
+ key="$(printf %s {shlex.quote(encoded_key)} | base64 -d)"
3474
+ printf '%s %s\\n' "$key" {shlex.quote(marker)} >> "$auth"
3475
+ chown ubuntu:ubuntu "$auth"
3476
+ chmod 600 "$auth"
3477
+ """
3478
+ result = client.run_command(computer_id, script, uid=0, gid=0, cwd="/", shell="/bin/bash")
3479
+ if int(result.get("exit_code") or 0) != 0:
3480
+ stderr = str(result.get("stderr") or "").strip()
3481
+ raise RuntimeConfigError(stderr or "failed to install temporary SSH key")
3482
+
3483
+
3484
+ def _remove_temporary_ssh_key(client: RuntimeClient, computer_id: str, marker: str) -> None:
3485
+ script = f"""set -euo pipefail
3486
+ auth=/home/ubuntu/.ssh/authorized_keys
3487
+ [ -f "$auth" ] || exit 0
3488
+ tmp="$(mktemp)"
3489
+ grep -Fv -- {shlex.quote(marker)} "$auth" > "$tmp" || true
3490
+ cat "$tmp" > "$auth"
3491
+ rm -f "$tmp"
3492
+ chown ubuntu:ubuntu "$auth"
3493
+ chmod 600 "$auth"
3494
+ """
3495
+ try:
3496
+ result = client.run_command(computer_id, script, uid=0, gid=0, cwd="/", shell="/bin/bash")
3497
+ except RuntimeAPIError as exc:
3498
+ print(f"warning: failed to remove temporary SSH key: {exc}", file=sys.stderr)
3499
+ return
3500
+ if int(result.get("exit_code") or 0) != 0:
3501
+ stderr = str(result.get("stderr") or "").strip()
3502
+ print(f"warning: failed to remove temporary SSH key: {stderr or 'cleanup command failed'}", file=sys.stderr)
3503
+
3504
+
3505
+ def _start_temporary_proxy(base_url: str, api_key: str, computer_id: str, remote_port: int) -> tuple[int, Callable[[], None]]:
3506
+ if not api_key:
3507
+ raise RuntimeAPIError("proxy agent missing api key")
3508
+
3509
+ _wait_for_proxy_preflight(base_url, api_key, computer_id, remote_port)
3510
+
3511
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
3512
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
3513
+ try:
3514
+ listener.bind(("127.0.0.1", 0))
3515
+ listener.listen()
3516
+ listener.settimeout(1.0)
3517
+ except OSError as exc:
3518
+ with contextlib.suppress(OSError):
3519
+ listener.close()
3520
+ raise RuntimeAPIError(f"proxy listen failed: {exc}") from exc
3521
+
3522
+ local_port = int(listener.getsockname()[1])
3523
+ stop = threading.Event()
3524
+
3525
+ def serve() -> None:
3526
+ try:
3527
+ while not stop.is_set():
3528
+ try:
3529
+ conn, _ = listener.accept()
3530
+ except socket.timeout:
3531
+ continue
3532
+ except OSError:
3533
+ if stop.is_set():
3534
+ break
3535
+ continue
3536
+ thread = threading.Thread(
3537
+ target=_handle_proxy_client,
3538
+ args=(base_url, api_key, computer_id, remote_port, conn),
3539
+ daemon=True,
3540
+ )
3541
+ thread.start()
3542
+ finally:
3543
+ with contextlib.suppress(OSError):
3544
+ listener.close()
3545
+
3546
+ thread = threading.Thread(target=serve, daemon=True)
3547
+ thread.start()
3548
+
3549
+ def cleanup() -> None:
3550
+ stop.set()
3551
+ with contextlib.suppress(OSError):
3552
+ listener.close()
3553
+ thread.join(timeout=2)
3554
+
3555
+ return local_port, cleanup
3556
+
3557
+
3558
+ def _wait_for_proxy_preflight(base_url: str, api_key: str, computer_id: str, remote_port: int) -> None:
3559
+ deadline = time.monotonic() + 20
3560
+ last_error: Exception | None = None
3561
+ while time.monotonic() < deadline:
3562
+ try:
3563
+ _proxy_preflight(base_url, api_key, computer_id, remote_port)
3564
+ return
3565
+ except Exception as exc:
3566
+ last_error = exc
3567
+ time.sleep(0.5)
3568
+ raise RuntimeAPIError(f"proxy preflight failed for {computer_id}:{remote_port}: {last_error}")
3569
+
3570
+
2676
3571
  def handle_delete(config: RuntimeConfig, computer_id: str | None, *, force: bool = False) -> int:
2677
3572
  if (err := _require_api_key(config)) is not None:
2678
3573
  return err
@@ -3761,11 +4656,17 @@ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
3761
4656
  _HELP_TOPICS: dict[str, tuple[str, ...]] = {
3762
4657
  "computer": ("Computers",),
3763
4658
  "computers": ("Computers",),
4659
+ "ssh": ("Computers",),
3764
4660
  "share": ("Sharing",),
3765
4661
  "sharing": ("Sharing",),
3766
4662
  "service": ("Startup & service",),
4663
+ "goal": ("Goals",),
4664
+ "goals": ("Goals",),
3767
4665
  "proxy": ("Proxy",),
3768
4666
  "github": ("GitHub",),
4667
+ "codex": ("Account",),
4668
+ "claude": ("Account",),
4669
+ "xai": ("Account",),
3769
4670
  "account": ("Account",),
3770
4671
  "shell": ("Shell",),
3771
4672
  "all": tuple(title for title, _ in _HELP_SECTIONS),
@@ -3804,12 +4705,12 @@ _runtime_complete() {
3804
4705
  COMPREPLY=()
3805
4706
  cur="${COMP_WORDS[COMP_CWORD]}"
3806
4707
  if [[ ${COMP_CWORD} -eq 1 ]]; then
3807
- COMPREPLY=( $(compgen -W "create list ls info url start enter run publish delete rm share service proxy switch checkout github integrate secrets api-keys signup verify login whoami logout completion help" -- "$cur") )
4708
+ COMPREPLY=( $(compgen -W "create list ls info url start enter ssh run publish delete rm share service proxy switch checkout github integrate secrets codex claude xai api-keys signup verify login whoami logout completion help" -- "$cur") )
3808
4709
  return
3809
4710
  fi
3810
4711
  sub="${COMP_WORDS[1]}"
3811
4712
  case "$sub" in
3812
- info|url|start|enter|run|publish|delete|rm|startup|service)
4713
+ info|url|start|enter|ssh|run|publish|delete|rm|startup|service)
3813
4714
  local ids
3814
4715
  ids=$(runtime _slugs 2>/dev/null)
3815
4716
  COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
@@ -3817,6 +4718,15 @@ _runtime_complete() {
3817
4718
  completion)
3818
4719
  COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
3819
4720
  ;;
4721
+ codex)
4722
+ COMPREPLY=( $(compgen -W "login status logout" -- "$cur") )
4723
+ ;;
4724
+ claude)
4725
+ COMPREPLY=( $(compgen -W "login status logout" -- "$cur") )
4726
+ ;;
4727
+ xai)
4728
+ COMPREPLY=( $(compgen -W "login status logout" -- "$cur") )
4729
+ ;;
3820
4730
  share)
3821
4731
  if [[ ${COMP_CWORD} -eq 2 ]]; then
3822
4732
  COMPREPLY=( $(compgen -W "public private" -- "$cur") )
@@ -3843,6 +4753,7 @@ _runtime() {
3843
4753
  'url:Show public URL'
3844
4754
  'start:Wake a cold computer'
3845
4755
  'enter:Enter console'
4756
+ 'ssh:SSH through Runtime'
3846
4757
  'run:Run a command'
3847
4758
  'publish:Publish a port'
3848
4759
  'delete:Delete a computer'
@@ -3855,6 +4766,9 @@ _runtime() {
3855
4766
  'github:GitHub connections'
3856
4767
  'integrate:Connect GitHub'
3857
4768
  'secrets:Secret sets'
4769
+ 'codex:Codex login'
4770
+ 'claude:Claude login'
4771
+ 'xai:xAI login'
3858
4772
  'api-keys:API keys'
3859
4773
  'signup:Send verification code'
3860
4774
  'verify:Verify code'
@@ -3872,7 +4786,7 @@ _runtime() {
3872
4786
  ;;
3873
4787
  args)
3874
4788
  case ${words[1]} in
3875
- info|url|start|enter|run|publish|delete|rm|startup|service)
4789
+ info|url|start|enter|ssh|run|publish|delete|rm|startup|service)
3876
4790
  local -a ids
3877
4791
  ids=(${(f)"$(runtime _slugs 2>/dev/null)"})
3878
4792
  _describe -t slugs 'computer' ids
@@ -3889,6 +4803,15 @@ _runtime() {
3889
4803
  completion)
3890
4804
  _values 'shell' bash zsh fish
3891
4805
  ;;
4806
+ codex)
4807
+ _values 'codex action' login status logout
4808
+ ;;
4809
+ claude)
4810
+ _values 'claude action' login status logout
4811
+ ;;
4812
+ xai)
4813
+ _values 'xai action' login status logout
4814
+ ;;
3892
4815
  esac
3893
4816
  ;;
3894
4817
  esac
@@ -3910,6 +4833,7 @@ complete -c runtime -n '__fish_use_subcommand' -a info -d 'Show details'
3910
4833
  complete -c runtime -n '__fish_use_subcommand' -a url -d 'Show URL'
3911
4834
  complete -c runtime -n '__fish_use_subcommand' -a start -d 'Wake'
3912
4835
  complete -c runtime -n '__fish_use_subcommand' -a enter -d 'Console'
4836
+ complete -c runtime -n '__fish_use_subcommand' -a ssh -d 'SSH'
3913
4837
  complete -c runtime -n '__fish_use_subcommand' -a run -d 'Run command'
3914
4838
  complete -c runtime -n '__fish_use_subcommand' -a publish -d 'Publish port'
3915
4839
  complete -c runtime -n '__fish_use_subcommand' -a delete -d 'Delete'
@@ -3919,12 +4843,18 @@ complete -c runtime -n '__fish_use_subcommand' -a service -d 'Service config'
3919
4843
  complete -c runtime -n '__fish_use_subcommand' -a proxy -d 'Port proxies'
3920
4844
  complete -c runtime -n '__fish_use_subcommand' -a github -d 'GitHub connections'
3921
4845
  complete -c runtime -n '__fish_use_subcommand' -a integrate -d 'Connect GitHub'
4846
+ complete -c runtime -n '__fish_use_subcommand' -a codex -d 'Codex login'
4847
+ complete -c runtime -n '__fish_use_subcommand' -a claude -d 'Claude login'
4848
+ complete -c runtime -n '__fish_use_subcommand' -a xai -d 'xAI login'
3922
4849
  complete -c runtime -n '__fish_use_subcommand' -a help -d 'Extended help'
3923
4850
  complete -c runtime -n '__fish_use_subcommand' -a completion -d 'Completion'
3924
4851
 
3925
- complete -c runtime -n '__fish_seen_subcommand_from info url start enter run publish delete rm service' -a '(__runtime_slugs)'
4852
+ complete -c runtime -n '__fish_seen_subcommand_from info url start enter ssh run publish delete rm service' -a '(__runtime_slugs)'
3926
4853
  complete -c runtime -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
3927
4854
  complete -c runtime -n '__fish_seen_subcommand_from share' -a 'public private'
4855
+ complete -c runtime -n '__fish_seen_subcommand_from codex' -a 'login status logout'
4856
+ complete -c runtime -n '__fish_seen_subcommand_from claude' -a 'login status logout'
4857
+ complete -c runtime -n '__fish_seen_subcommand_from xai' -a 'login status logout'
3928
4858
  """
3929
4859
 
3930
4860
 
@@ -84,6 +84,15 @@ class RuntimeClient:
84
84
  def delete_secret_set(self, slug: str) -> dict[str, Any]:
85
85
  return self._request("DELETE", f"/api/secrets/{slug}", auth_required=True)
86
86
 
87
+ def list_provider_credentials(self) -> dict[str, Any]:
88
+ return self._request("GET", "/api/provider-credentials", auth_required=True)
89
+
90
+ def put_provider_credential(self, provider: str, auth_json: str) -> dict[str, Any]:
91
+ return self._request("PUT", f"/api/provider-credentials/{provider}", json={"auth_json": auth_json}, auth_required=True)
92
+
93
+ def delete_provider_credential(self, provider: str) -> dict[str, Any]:
94
+ return self._request("DELETE", f"/api/provider-credentials/{provider}", auth_required=True)
95
+
87
96
  # --- Computers ---
88
97
 
89
98
  def switch_workspace(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runtime-sdk
3
- Version: 0.4.38
3
+ Version: 0.4.39
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
@@ -79,6 +79,8 @@ runtime create myapp --command "python3 app.py" --cwd /home/ubuntu --port 3000
79
79
  runtime enter <name-or-id> # open an interactive shell; accepts slug/name or computer id
80
80
  runtime enter <name-or-id> -- claude # open a real TTY and run an interactive command
81
81
  runtime enter <name-or-id> -- python # use enter for REPLs, agents, prompts, and full-screen apps
82
+ runtime ssh <name-or-id> # open SSH through Runtime's authenticated tunnel
83
+ runtime ssh <name-or-id> -- uptime # run a command with the local ssh client
82
84
  runtime list
83
85
  runtime info <id>
84
86
  runtime share public <id>
@@ -196,6 +198,10 @@ runtime switch -c feature/search --repo owner/repo --from main
196
198
  Rules:
197
199
  - GitHub must already be connected with `runtime integrate github`.
198
200
  - Run `runtime integrate github` again when you want to add another personal account or org.
201
+ - New GitHub connects require user authorization during installation and
202
+ `GITHUB_APP_CLIENT_ID` / `GITHUB_APP_CLIENT_SECRET` on the Runtime API.
203
+ In GitHub App settings, enable user authorization during installation and set
204
+ the callback URL to `https://api.runruntime.dev/github/callback`.
199
205
  - `runtime switch` opens an existing branch workspace, or creates the computer if
200
206
  the branch exists but the workspace does not yet.
201
207
  - `runtime switch -c` creates a new branch first, then opens that branch's
File without changes