runtime-sdk 0.4.37__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.
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/PKG-INFO +7 -1
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/README.md +6 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/pyproject.toml +1 -1
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk/cli.py +957 -7
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk/client.py +9 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/PKG-INFO +7 -1
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.4.37 → runtime_sdk-0.4.39}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runtime-sdk
|
|
3
|
-
Version: 0.4.
|
|
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,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
|
)
|
|
@@ -544,8 +598,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
544
598
|
return report_error("runtime-sdk supports macOS and Linux only; Windows is not supported")
|
|
545
599
|
|
|
546
600
|
try:
|
|
547
|
-
|
|
548
|
-
|
|
601
|
+
enter_id: str | None = None
|
|
602
|
+
enter_command: list[str] | None = None
|
|
603
|
+
if args.command == "enter":
|
|
604
|
+
enter_id, enter_command, enter_err = _parse_enter_invocation(raw_argv, args.id, args.enter_command)
|
|
605
|
+
if enter_err:
|
|
606
|
+
return report_error(enter_err)
|
|
549
607
|
|
|
550
608
|
config = load_config()
|
|
551
609
|
if args.base_url:
|
|
@@ -597,6 +655,30 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
597
655
|
if args.secrets_action in ("delete", "rm"):
|
|
598
656
|
return handle_secrets_delete(config, args.slug)
|
|
599
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")
|
|
600
682
|
if args.command == "github":
|
|
601
683
|
if args.github_action in ("list", "ls"):
|
|
602
684
|
return handle_github_list(config)
|
|
@@ -632,7 +714,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
632
714
|
if args.command == "start":
|
|
633
715
|
return handle_start(config, args.id)
|
|
634
716
|
if args.command == "enter":
|
|
635
|
-
return handle_enter(config,
|
|
717
|
+
return handle_enter(config, enter_id, enter_command)
|
|
718
|
+
if args.command == "ssh":
|
|
719
|
+
return handle_ssh(config, args.id, args.ssh_args)
|
|
636
720
|
if args.command in ("delete", "rm"):
|
|
637
721
|
return handle_delete(config, args.id, force=getattr(args, "force", False))
|
|
638
722
|
if args.command == "run":
|
|
@@ -667,6 +751,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
667
751
|
if args.service_action == "clear":
|
|
668
752
|
return handle_service_clear(config, args.id)
|
|
669
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")
|
|
670
758
|
if args.command == "proxy":
|
|
671
759
|
if args.proxy_command == "start":
|
|
672
760
|
return handle_proxy_start(config, args.id, args.ports)
|
|
@@ -709,6 +797,22 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
709
797
|
# --------------------------------------------------------------------------- #
|
|
710
798
|
|
|
711
799
|
|
|
800
|
+
def _parse_enter_invocation(raw_argv: list[str], parsed_id: str | None, parsed_command: list[str]) -> tuple[str | None, list[str], str | None]:
|
|
801
|
+
enter_index = raw_argv.index("enter")
|
|
802
|
+
enter_args = raw_argv[enter_index + 1:]
|
|
803
|
+
if "--" not in enter_args:
|
|
804
|
+
if parsed_command:
|
|
805
|
+
return None, [], "enter command must be separated from the computer id with --"
|
|
806
|
+
return parsed_id, parsed_command, None
|
|
807
|
+
|
|
808
|
+
separator_index = enter_args.index("--")
|
|
809
|
+
before_separator = enter_args[:separator_index]
|
|
810
|
+
if len(before_separator) > 1:
|
|
811
|
+
return None, [], "enter accepts at most one computer id before --"
|
|
812
|
+
computer_id = before_separator[0] if before_separator else None
|
|
813
|
+
return computer_id, enter_args[separator_index + 1:], None
|
|
814
|
+
|
|
815
|
+
|
|
712
816
|
def write_json(payload: dict[str, Any], *, error: bool = False) -> int:
|
|
713
817
|
stream = sys.stderr if error else sys.stdout
|
|
714
818
|
stream.write(json.dumps(payload) + "\n")
|
|
@@ -1614,6 +1718,17 @@ def _render_secret_set_panel(payload: dict[str, Any]) -> None:
|
|
|
1614
1718
|
_UI.console().print(Panel(body, title="secret set", border_style="cyan", expand=False))
|
|
1615
1719
|
|
|
1616
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
|
+
|
|
1617
1732
|
# --------------------------------------------------------------------------- #
|
|
1618
1733
|
# Command handlers #
|
|
1619
1734
|
# --------------------------------------------------------------------------- #
|
|
@@ -2032,6 +2147,305 @@ def handle_secrets_delete(config: RuntimeConfig, slug: str | None) -> int:
|
|
|
2032
2147
|
)
|
|
2033
2148
|
|
|
2034
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
|
+
|
|
2035
2449
|
|
|
2036
2450
|
def handle_github_list(config: RuntimeConfig) -> int:
|
|
2037
2451
|
if (err := _require_api_key(config)) is not None:
|
|
@@ -2554,6 +2968,300 @@ def handle_service_logs(config: RuntimeConfig, computer_id: str | None, follow:
|
|
|
2554
2968
|
return 0
|
|
2555
2969
|
|
|
2556
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
|
+
|
|
2557
3265
|
def handle_service_restart(config: RuntimeConfig, computer_id: str | None) -> int:
|
|
2558
3266
|
if (err := _require_api_key(config)) is not None:
|
|
2559
3267
|
return err
|
|
@@ -2653,6 +3361,213 @@ def handle_enter(config: RuntimeConfig, computer_id: str | None, command_parts:
|
|
|
2653
3361
|
return _enter_computer(config, str(computer_id))
|
|
2654
3362
|
|
|
2655
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
|
+
|
|
2656
3571
|
def handle_delete(config: RuntimeConfig, computer_id: str | None, *, force: bool = False) -> int:
|
|
2657
3572
|
if (err := _require_api_key(config)) is not None:
|
|
2658
3573
|
return err
|
|
@@ -3741,11 +4656,17 @@ def _vm_startup_submenu(client: RuntimeClient, vm_id: str, slug: str) -> None:
|
|
|
3741
4656
|
_HELP_TOPICS: dict[str, tuple[str, ...]] = {
|
|
3742
4657
|
"computer": ("Computers",),
|
|
3743
4658
|
"computers": ("Computers",),
|
|
4659
|
+
"ssh": ("Computers",),
|
|
3744
4660
|
"share": ("Sharing",),
|
|
3745
4661
|
"sharing": ("Sharing",),
|
|
3746
4662
|
"service": ("Startup & service",),
|
|
4663
|
+
"goal": ("Goals",),
|
|
4664
|
+
"goals": ("Goals",),
|
|
3747
4665
|
"proxy": ("Proxy",),
|
|
3748
4666
|
"github": ("GitHub",),
|
|
4667
|
+
"codex": ("Account",),
|
|
4668
|
+
"claude": ("Account",),
|
|
4669
|
+
"xai": ("Account",),
|
|
3749
4670
|
"account": ("Account",),
|
|
3750
4671
|
"shell": ("Shell",),
|
|
3751
4672
|
"all": tuple(title for title, _ in _HELP_SECTIONS),
|
|
@@ -3784,12 +4705,12 @@ _runtime_complete() {
|
|
|
3784
4705
|
COMPREPLY=()
|
|
3785
4706
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
3786
4707
|
if [[ ${COMP_CWORD} -eq 1 ]]; then
|
|
3787
|
-
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") )
|
|
3788
4709
|
return
|
|
3789
4710
|
fi
|
|
3790
4711
|
sub="${COMP_WORDS[1]}"
|
|
3791
4712
|
case "$sub" in
|
|
3792
|
-
info|url|start|enter|run|publish|delete|rm|startup|service)
|
|
4713
|
+
info|url|start|enter|ssh|run|publish|delete|rm|startup|service)
|
|
3793
4714
|
local ids
|
|
3794
4715
|
ids=$(runtime _slugs 2>/dev/null)
|
|
3795
4716
|
COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
|
|
@@ -3797,6 +4718,15 @@ _runtime_complete() {
|
|
|
3797
4718
|
completion)
|
|
3798
4719
|
COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
|
|
3799
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
|
+
;;
|
|
3800
4730
|
share)
|
|
3801
4731
|
if [[ ${COMP_CWORD} -eq 2 ]]; then
|
|
3802
4732
|
COMPREPLY=( $(compgen -W "public private" -- "$cur") )
|
|
@@ -3823,6 +4753,7 @@ _runtime() {
|
|
|
3823
4753
|
'url:Show public URL'
|
|
3824
4754
|
'start:Wake a cold computer'
|
|
3825
4755
|
'enter:Enter console'
|
|
4756
|
+
'ssh:SSH through Runtime'
|
|
3826
4757
|
'run:Run a command'
|
|
3827
4758
|
'publish:Publish a port'
|
|
3828
4759
|
'delete:Delete a computer'
|
|
@@ -3835,6 +4766,9 @@ _runtime() {
|
|
|
3835
4766
|
'github:GitHub connections'
|
|
3836
4767
|
'integrate:Connect GitHub'
|
|
3837
4768
|
'secrets:Secret sets'
|
|
4769
|
+
'codex:Codex login'
|
|
4770
|
+
'claude:Claude login'
|
|
4771
|
+
'xai:xAI login'
|
|
3838
4772
|
'api-keys:API keys'
|
|
3839
4773
|
'signup:Send verification code'
|
|
3840
4774
|
'verify:Verify code'
|
|
@@ -3852,7 +4786,7 @@ _runtime() {
|
|
|
3852
4786
|
;;
|
|
3853
4787
|
args)
|
|
3854
4788
|
case ${words[1]} in
|
|
3855
|
-
info|url|start|enter|run|publish|delete|rm|startup|service)
|
|
4789
|
+
info|url|start|enter|ssh|run|publish|delete|rm|startup|service)
|
|
3856
4790
|
local -a ids
|
|
3857
4791
|
ids=(${(f)"$(runtime _slugs 2>/dev/null)"})
|
|
3858
4792
|
_describe -t slugs 'computer' ids
|
|
@@ -3869,6 +4803,15 @@ _runtime() {
|
|
|
3869
4803
|
completion)
|
|
3870
4804
|
_values 'shell' bash zsh fish
|
|
3871
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
|
+
;;
|
|
3872
4815
|
esac
|
|
3873
4816
|
;;
|
|
3874
4817
|
esac
|
|
@@ -3890,6 +4833,7 @@ complete -c runtime -n '__fish_use_subcommand' -a info -d 'Show details'
|
|
|
3890
4833
|
complete -c runtime -n '__fish_use_subcommand' -a url -d 'Show URL'
|
|
3891
4834
|
complete -c runtime -n '__fish_use_subcommand' -a start -d 'Wake'
|
|
3892
4835
|
complete -c runtime -n '__fish_use_subcommand' -a enter -d 'Console'
|
|
4836
|
+
complete -c runtime -n '__fish_use_subcommand' -a ssh -d 'SSH'
|
|
3893
4837
|
complete -c runtime -n '__fish_use_subcommand' -a run -d 'Run command'
|
|
3894
4838
|
complete -c runtime -n '__fish_use_subcommand' -a publish -d 'Publish port'
|
|
3895
4839
|
complete -c runtime -n '__fish_use_subcommand' -a delete -d 'Delete'
|
|
@@ -3899,12 +4843,18 @@ complete -c runtime -n '__fish_use_subcommand' -a service -d 'Service config'
|
|
|
3899
4843
|
complete -c runtime -n '__fish_use_subcommand' -a proxy -d 'Port proxies'
|
|
3900
4844
|
complete -c runtime -n '__fish_use_subcommand' -a github -d 'GitHub connections'
|
|
3901
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'
|
|
3902
4849
|
complete -c runtime -n '__fish_use_subcommand' -a help -d 'Extended help'
|
|
3903
4850
|
complete -c runtime -n '__fish_use_subcommand' -a completion -d 'Completion'
|
|
3904
4851
|
|
|
3905
|
-
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)'
|
|
3906
4853
|
complete -c runtime -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
|
|
3907
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'
|
|
3908
4858
|
"""
|
|
3909
4859
|
|
|
3910
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|