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.
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/PKG-INFO +7 -1
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/README.md +6 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/pyproject.toml +1 -1
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk/cli.py +934 -4
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk/client.py +9 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/PKG-INFO +7 -1
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk/__init__.py +0 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk/config.py +0 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/SOURCES.txt +0 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/dependency_links.txt +0 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/entry_points.txt +0 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/requires.txt +0 -0
- {runtime_sdk-0.4.38 → runtime_sdk-0.4.39}/scripts/runtime_sdk.egg-info/top_level.txt +0 -0
- {runtime_sdk-0.4.38 → 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
|
)
|
|
@@ -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.
|
|
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
|