virtuai-cli 0.1.0__py3-none-any.whl
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.
- virtuai_cli/__init__.py +2 -0
- virtuai_cli/config.py +115 -0
- virtuai_cli/executor.py +88 -0
- virtuai_cli/main.py +234 -0
- virtuai_cli/runner.py +155 -0
- virtuai_cli/security.py +88 -0
- virtuai_cli-0.1.0.dist-info/METADATA +79 -0
- virtuai_cli-0.1.0.dist-info/RECORD +11 -0
- virtuai_cli-0.1.0.dist-info/WHEEL +5 -0
- virtuai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- virtuai_cli-0.1.0.dist-info/top_level.txt +1 -0
virtuai_cli/__init__.py
ADDED
virtuai_cli/config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Persistent config stored in ~/.virtuai/config.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_CONFIG_DIR = Path.home() / ".virtuai"
|
|
13
|
+
_CONFIG_FILE = _CONFIG_DIR / "config.json"
|
|
14
|
+
_CREDENTIALS_FILE = _CONFIG_DIR / "credentials.json" # fallback when keyring unavailable
|
|
15
|
+
|
|
16
|
+
_KEYRING_SERVICE = "virtuai-cli"
|
|
17
|
+
_KEYRING_CLI_TOKEN_KEY = "cli_token"
|
|
18
|
+
_KEYRING_USER_TOKEN_KEY = "user_token"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ensure_dir() -> None:
|
|
22
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load() -> dict:
|
|
26
|
+
if not _CONFIG_FILE.exists():
|
|
27
|
+
return {}
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(_CONFIG_FILE.read_text())
|
|
30
|
+
except Exception:
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save(data: dict) -> None:
|
|
35
|
+
_ensure_dir()
|
|
36
|
+
_CONFIG_FILE.write_text(json.dumps(data, indent=2))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get(key: str, default: Any = None) -> Any:
|
|
40
|
+
return load().get(key, default)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def set_key(key: str, value: Any) -> None:
|
|
44
|
+
data = load()
|
|
45
|
+
data[key] = value
|
|
46
|
+
save(data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_server_url() -> str:
|
|
50
|
+
return get("server_url", "https://app.virtuai.io")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Token storage — OS keychain with ~/.virtuai/credentials fallback
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def _keyring_available() -> bool:
|
|
58
|
+
try:
|
|
59
|
+
import keyring
|
|
60
|
+
keyring.get_password(_KEYRING_SERVICE, "test")
|
|
61
|
+
return True
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def store_token(key: str, value: str) -> None:
|
|
67
|
+
try:
|
|
68
|
+
import keyring
|
|
69
|
+
keyring.set_password(_KEYRING_SERVICE, key, value)
|
|
70
|
+
return
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
# Fallback: encrypted-ish plaintext with strict permissions
|
|
74
|
+
_ensure_dir()
|
|
75
|
+
creds: dict = {}
|
|
76
|
+
if _CREDENTIALS_FILE.exists():
|
|
77
|
+
try:
|
|
78
|
+
creds = json.loads(_CREDENTIALS_FILE.read_text())
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
creds[key] = value
|
|
82
|
+
_CREDENTIALS_FILE.write_text(json.dumps(creds))
|
|
83
|
+
_CREDENTIALS_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_token(key: str) -> Optional[str]:
|
|
87
|
+
try:
|
|
88
|
+
import keyring
|
|
89
|
+
val = keyring.get_password(_KEYRING_SERVICE, key)
|
|
90
|
+
if val:
|
|
91
|
+
return val
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
if _CREDENTIALS_FILE.exists():
|
|
95
|
+
try:
|
|
96
|
+
creds = json.loads(_CREDENTIALS_FILE.read_text())
|
|
97
|
+
return creds.get(key)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def delete_token(key: str) -> None:
|
|
104
|
+
try:
|
|
105
|
+
import keyring
|
|
106
|
+
keyring.delete_password(_KEYRING_SERVICE, key)
|
|
107
|
+
except Exception:
|
|
108
|
+
pass
|
|
109
|
+
if _CREDENTIALS_FILE.exists():
|
|
110
|
+
try:
|
|
111
|
+
creds = json.loads(_CREDENTIALS_FILE.read_text())
|
|
112
|
+
creds.pop(key, None)
|
|
113
|
+
_CREDENTIALS_FILE.write_text(json.dumps(creds))
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
virtuai_cli/executor.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Execute commands and file operations inside the local workdir jail."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from virtuai_cli.security import check_command, jail_path, scrub_env
|
|
11
|
+
|
|
12
|
+
_EXECUTE_TIMEOUT = 300 # seconds
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def execute(command: str, workdir: Path, timeout: int = _EXECUTE_TIMEOUT, extra_env: list[str] | None = None) -> dict:
|
|
16
|
+
"""Run a shell command. Returns {output, exit_code}."""
|
|
17
|
+
denial = check_command(command)
|
|
18
|
+
if denial:
|
|
19
|
+
return {"output": f"blocked by local denylist: {denial}", "exit_code": 126}
|
|
20
|
+
|
|
21
|
+
env = scrub_env(workdir, extra_env)
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
command,
|
|
25
|
+
shell=True,
|
|
26
|
+
cwd=str(workdir),
|
|
27
|
+
env=env,
|
|
28
|
+
capture_output=True,
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
parts = []
|
|
32
|
+
if result.stdout:
|
|
33
|
+
parts.append(result.stdout.decode(errors="replace"))
|
|
34
|
+
if result.stderr:
|
|
35
|
+
parts.append(result.stderr.decode(errors="replace"))
|
|
36
|
+
return {"output": "\n".join(parts), "exit_code": result.returncode}
|
|
37
|
+
except subprocess.TimeoutExpired:
|
|
38
|
+
return {"output": f"command timed out after {timeout}s", "exit_code": 124}
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
return {"output": f"executor error: {exc}", "exit_code": 1}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def upload_files(files: list[dict], workdir: Path) -> list[dict]:
|
|
44
|
+
"""Write files sent by the server. Rejects paths that escape the workdir."""
|
|
45
|
+
results = []
|
|
46
|
+
for f in files:
|
|
47
|
+
path_str: str = f["path"]
|
|
48
|
+
content_b64: str = f["content_b64"]
|
|
49
|
+
|
|
50
|
+
dest = jail_path(path_str, workdir)
|
|
51
|
+
if dest is None:
|
|
52
|
+
results.append({"path": path_str, "error": "path escapes workdir"})
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
dest.write_bytes(base64.b64decode(content_b64))
|
|
58
|
+
results.append({"path": path_str, "error": None})
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
results.append({"path": path_str, "error": str(exc)})
|
|
61
|
+
|
|
62
|
+
return results
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def download_files(paths: list[str], workdir: Path) -> list[dict]:
|
|
66
|
+
"""Read files for the server. Rejects paths that escape the workdir."""
|
|
67
|
+
results = []
|
|
68
|
+
for path_str in paths:
|
|
69
|
+
dest = jail_path(path_str, workdir)
|
|
70
|
+
if dest is None:
|
|
71
|
+
results.append({"path": path_str, "error": "path escapes workdir"})
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if not dest.exists():
|
|
75
|
+
results.append({"path": path_str, "error": "file_not_found"})
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
content = dest.read_bytes()
|
|
80
|
+
results.append({
|
|
81
|
+
"path": path_str,
|
|
82
|
+
"content_b64": base64.b64encode(content).decode(),
|
|
83
|
+
"error": None,
|
|
84
|
+
})
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
results.append({"path": path_str, "error": str(exc)})
|
|
87
|
+
|
|
88
|
+
return results
|
virtuai_cli/main.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""VirtuAI CLI — entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import certifi
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
# httpx doesn't use the macOS system keychain either; point it at certifi.
|
|
16
|
+
_HTTPX_KWARGS: dict = {"verify": certifi.where()}
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from virtuai_cli import __version__
|
|
22
|
+
from virtuai_cli import config as cfg
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(name="virtuai", help="Run VirtuAI deep agents on your local machine.", add_completion=False)
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
_DEFAULT_WORKDIR = Path.cwd()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# login — browser OAuth flow (opens the portal login page with a CLI callback)
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def login(
|
|
36
|
+
server: Optional[str] = typer.Option(None, "--server", help="VirtuAI server URL"),
|
|
37
|
+
):
|
|
38
|
+
"""Authenticate this CLI with your VirtuAI account."""
|
|
39
|
+
url = server or cfg.get_server_url()
|
|
40
|
+
cfg.set_key("server_url", url)
|
|
41
|
+
console.print(f"Opening [bold]{url}[/bold] in your browser …")
|
|
42
|
+
try:
|
|
43
|
+
import webbrowser
|
|
44
|
+
webbrowser.open(f"{url}/cli-login?cli=1")
|
|
45
|
+
except Exception:
|
|
46
|
+
console.print(f"[yellow]Could not open browser. Visit:[/yellow] {url}/cli-login?cli=1")
|
|
47
|
+
|
|
48
|
+
user_token = typer.prompt("Paste the token from the browser")
|
|
49
|
+
cfg.store_token(cfg._KEYRING_USER_TOKEN_KEY, user_token.strip())
|
|
50
|
+
cfg.set_key("server_url", url)
|
|
51
|
+
console.print("[green]Logged in.[/green]")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# pair — exchange a one-time code for a long-lived CLI token
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def pair(
|
|
60
|
+
code: str = typer.Argument(..., help="8-character pairing code from the VirtuAI portal"),
|
|
61
|
+
server: Optional[str] = typer.Option(None, "--server"),
|
|
62
|
+
):
|
|
63
|
+
"""Pair this machine with a VirtuAI workspace."""
|
|
64
|
+
url = server or cfg.get_server_url()
|
|
65
|
+
fingerprint = socket.gethostname()
|
|
66
|
+
|
|
67
|
+
console.print(f"Pairing with [bold]{url}[/bold] …")
|
|
68
|
+
try:
|
|
69
|
+
resp = httpx.post(
|
|
70
|
+
f"{url}/api/cli/pair/complete",
|
|
71
|
+
json={"code": code.upper(), "host_fingerprint": fingerprint},
|
|
72
|
+
timeout=15,
|
|
73
|
+
**_HTTPX_KWARGS,
|
|
74
|
+
)
|
|
75
|
+
resp.raise_for_status()
|
|
76
|
+
data = resp.json()
|
|
77
|
+
except httpx.HTTPStatusError as e:
|
|
78
|
+
console.print(f"[red]Error:[/red] {e.response.status_code} — {e.response.text}")
|
|
79
|
+
raise typer.Exit(1)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
|
|
84
|
+
cli_token = data["cli_token"]
|
|
85
|
+
cfg.store_token(cfg._KEYRING_CLI_TOKEN_KEY, cli_token)
|
|
86
|
+
cfg.set_key("server_url", url)
|
|
87
|
+
console.print(f"[green]Paired.[/green] Run [bold]virtuai run[/bold] to start the local runner.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# unpair — revoke this machine's pairing
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def unpair():
|
|
96
|
+
"""Revoke the current pairing and clear stored credentials."""
|
|
97
|
+
token = cfg.load_token(cfg._KEYRING_CLI_TOKEN_KEY)
|
|
98
|
+
url = cfg.get_server_url()
|
|
99
|
+
user_token = cfg.load_token(cfg._KEYRING_USER_TOKEN_KEY)
|
|
100
|
+
|
|
101
|
+
if token and user_token:
|
|
102
|
+
try:
|
|
103
|
+
httpx.delete(
|
|
104
|
+
f"{url}/api/cli/pair",
|
|
105
|
+
headers={"Authorization": f"Bearer {user_token}"},
|
|
106
|
+
timeout=10,
|
|
107
|
+
**_HTTPX_KWARGS,
|
|
108
|
+
)
|
|
109
|
+
except Exception:
|
|
110
|
+
pass # best-effort; we clear locally regardless
|
|
111
|
+
|
|
112
|
+
cfg.delete_token(cfg._KEYRING_CLI_TOKEN_KEY)
|
|
113
|
+
console.print("[yellow]Unpaired.[/yellow] CLI token cleared.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# run — open the WebSocket and start accepting agent commands
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@app.command()
|
|
121
|
+
def run(
|
|
122
|
+
workdir: Optional[Path] = typer.Option(
|
|
123
|
+
None, "--workdir", help="Base directory for agent workspaces (default: ~/virtuai)"
|
|
124
|
+
),
|
|
125
|
+
server: Optional[str] = typer.Option(None, "--server"),
|
|
126
|
+
):
|
|
127
|
+
"""Start the local runner. Keep this running while using VirtuAI."""
|
|
128
|
+
url = server or cfg.get_server_url()
|
|
129
|
+
token = cfg.load_token(cfg._KEYRING_CLI_TOKEN_KEY)
|
|
130
|
+
if not token:
|
|
131
|
+
console.print("[red]Not paired.[/red] Run [bold]virtuai pair <code>[/bold] first.")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
base = workdir or _DEFAULT_WORKDIR
|
|
135
|
+
console.print(f"[bold]VirtuAI CLI[/bold] v{__version__} — Press Ctrl+C to stop.")
|
|
136
|
+
|
|
137
|
+
from virtuai_cli.runner import run_forever
|
|
138
|
+
try:
|
|
139
|
+
asyncio.run(run_forever(url, token, base))
|
|
140
|
+
except KeyboardInterrupt:
|
|
141
|
+
console.print("\n[yellow]Stopped.[/yellow]")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
# status — show pairing and connection state
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
@app.command()
|
|
149
|
+
def status(
|
|
150
|
+
server: Optional[str] = typer.Option(None, "--server"),
|
|
151
|
+
):
|
|
152
|
+
"""Show the current pairing and connection status."""
|
|
153
|
+
url = server or cfg.get_server_url()
|
|
154
|
+
user_token = cfg.load_token(cfg._KEYRING_USER_TOKEN_KEY)
|
|
155
|
+
cli_token = cfg.load_token(cfg._KEYRING_CLI_TOKEN_KEY)
|
|
156
|
+
|
|
157
|
+
table = Table(show_header=False)
|
|
158
|
+
table.add_row("Server", url)
|
|
159
|
+
table.add_row("CLI token", "stored" if cli_token else "[red]not paired[/red]")
|
|
160
|
+
table.add_row("User token", "stored" if user_token else "[yellow]not logged in[/yellow]")
|
|
161
|
+
|
|
162
|
+
if user_token and cli_token:
|
|
163
|
+
try:
|
|
164
|
+
workspace_id = cfg.get("workspace_id")
|
|
165
|
+
if workspace_id:
|
|
166
|
+
resp = httpx.get(
|
|
167
|
+
f"{url}/api/cli/status",
|
|
168
|
+
headers={
|
|
169
|
+
"Authorization": f"Bearer {user_token}",
|
|
170
|
+
"Workspace-ID": str(workspace_id),
|
|
171
|
+
},
|
|
172
|
+
timeout=5,
|
|
173
|
+
**_HTTPX_KWARGS,
|
|
174
|
+
)
|
|
175
|
+
if resp.status_code == 200:
|
|
176
|
+
data = resp.json()
|
|
177
|
+
table.add_row("Connection", data.get("status", "?"))
|
|
178
|
+
if data.get("workdir"):
|
|
179
|
+
table.add_row("Workdir", data["workdir"])
|
|
180
|
+
except Exception:
|
|
181
|
+
table.add_row("Connection", "[dim]unknown[/dim]")
|
|
182
|
+
|
|
183
|
+
console.print(table)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# logs — show recent commands executed locally
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
@app.command()
|
|
191
|
+
def logs(
|
|
192
|
+
tail: int = typer.Option(20, "--tail", "-n", help="Number of recent entries to show"),
|
|
193
|
+
):
|
|
194
|
+
"""Show recent commands executed by the agent on this machine."""
|
|
195
|
+
audit_file = Path.home() / ".virtuai" / "audit.log"
|
|
196
|
+
if not audit_file.exists():
|
|
197
|
+
console.print("[dim]No audit log yet.[/dim]")
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
lines = audit_file.read_text().splitlines()
|
|
201
|
+
for line in lines[-tail:]:
|
|
202
|
+
console.print(line)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# config — get/set local options
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
@app.command(name="config")
|
|
210
|
+
def config_cmd(
|
|
211
|
+
action: str = typer.Argument(..., help="get or set"),
|
|
212
|
+
key: Optional[str] = typer.Argument(None),
|
|
213
|
+
value: Optional[str] = typer.Argument(None),
|
|
214
|
+
):
|
|
215
|
+
"""Get or set local CLI configuration."""
|
|
216
|
+
if action == "get":
|
|
217
|
+
if key:
|
|
218
|
+
console.print(cfg.get(key))
|
|
219
|
+
else:
|
|
220
|
+
import json
|
|
221
|
+
console.print(json.dumps(cfg.load(), indent=2))
|
|
222
|
+
elif action == "set":
|
|
223
|
+
if not key or value is None:
|
|
224
|
+
console.print("[red]Usage:[/red] virtuai config set <key> <value>")
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
cfg.set_key(key, value)
|
|
227
|
+
console.print(f"[green]Set[/green] {key} = {value}")
|
|
228
|
+
else:
|
|
229
|
+
console.print(f"[red]Unknown action:[/red] {action}")
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
app()
|
virtuai_cli/runner.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""WebSocket runner — connects to VirtuAI and dispatches agent commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
_AUDIT_DIR = Path.home() / ".virtuai"
|
|
15
|
+
|
|
16
|
+
import websockets
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
from virtuai_cli import __version__
|
|
20
|
+
from virtuai_cli import executor as exec_
|
|
21
|
+
from virtuai_cli.security import resolve_workdir
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _audit(workdir: Path, command: str, exit_code: int, elapsed: float) -> None:
|
|
28
|
+
audit_file = _AUDIT_DIR / "audit.log"
|
|
29
|
+
_AUDIT_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
try:
|
|
31
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
32
|
+
line = f"{ts}\t{exit_code}\t{elapsed:.1f}s\t{command}\n"
|
|
33
|
+
with open(audit_file, "a") as fh:
|
|
34
|
+
fh.write(line)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
_RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30] # backoff steps
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _handle_frame(frame: dict, workdir: Path, ws) -> None:
|
|
42
|
+
"""Dispatch a server frame and send the result back."""
|
|
43
|
+
ftype = frame.get("type")
|
|
44
|
+
fid = frame.get("id")
|
|
45
|
+
|
|
46
|
+
if ftype == "hello":
|
|
47
|
+
reply = {
|
|
48
|
+
"v": 1,
|
|
49
|
+
"id": fid,
|
|
50
|
+
"type": "ready",
|
|
51
|
+
"cli_version": __version__,
|
|
52
|
+
"os": platform.system(),
|
|
53
|
+
"arch": platform.machine(),
|
|
54
|
+
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
55
|
+
"workdir": str(workdir),
|
|
56
|
+
}
|
|
57
|
+
await ws.send(json.dumps(reply))
|
|
58
|
+
console.print(f"[green]Connected.[/green] Workdir: [bold]{workdir}[/bold]")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
if ftype == "ping":
|
|
62
|
+
await ws.send(json.dumps({"v": 1, "id": fid, "type": "pong", "ts": time.time()}))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
if ftype == "execute":
|
|
66
|
+
command = frame.get("command", "")
|
|
67
|
+
timeout = int(frame.get("timeout_secs", 300))
|
|
68
|
+
console.print(f"[dim]exec:[/dim] {command[:80]}{'...' if len(command) > 80 else ''}")
|
|
69
|
+
t0 = time.monotonic()
|
|
70
|
+
loop = asyncio.get_event_loop()
|
|
71
|
+
result = await loop.run_in_executor(
|
|
72
|
+
None, lambda: exec_.execute(command, workdir, timeout=timeout)
|
|
73
|
+
)
|
|
74
|
+
elapsed = time.monotonic() - t0
|
|
75
|
+
_audit(workdir, command, result["exit_code"], elapsed)
|
|
76
|
+
await ws.send(json.dumps({
|
|
77
|
+
"v": 1,
|
|
78
|
+
"id": fid,
|
|
79
|
+
"type": "execute_result",
|
|
80
|
+
"output": result["output"],
|
|
81
|
+
"exit_code": result["exit_code"],
|
|
82
|
+
}))
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if ftype == "upload":
|
|
86
|
+
files = frame.get("files", [])
|
|
87
|
+
loop = asyncio.get_event_loop()
|
|
88
|
+
results = await loop.run_in_executor(None, lambda: exec_.upload_files(files, workdir))
|
|
89
|
+
await ws.send(json.dumps({"v": 1, "id": fid, "type": "upload_result", "results": results}))
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if ftype == "download":
|
|
93
|
+
paths = frame.get("paths", [])
|
|
94
|
+
loop = asyncio.get_event_loop()
|
|
95
|
+
results = await loop.run_in_executor(None, lambda: exec_.download_files(paths, workdir))
|
|
96
|
+
await ws.send(json.dumps({"v": 1, "id": fid, "type": "download_result", "results": results}))
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
if ftype == "shutdown":
|
|
100
|
+
console.print(f"[yellow]Server requested shutdown:[/yellow] {frame.get('reason', '')}")
|
|
101
|
+
raise SystemExit(0)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ssl_context():
|
|
105
|
+
"""Return an SSL context that trusts certifi's CA bundle.
|
|
106
|
+
|
|
107
|
+
On macOS, the Python shipped by python.org doesn't use the system
|
|
108
|
+
keychain, so WSS connections fail with CERTIFICATE_VERIFY_FAILED unless
|
|
109
|
+
we explicitly point it at certifi's CA bundle.
|
|
110
|
+
"""
|
|
111
|
+
import ssl
|
|
112
|
+
import certifi
|
|
113
|
+
ctx = ssl.create_default_context(cafile=certifi.where())
|
|
114
|
+
return ctx
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def run_session(ws_url: str, token: str, workdir: Path) -> None:
|
|
118
|
+
"""Run a single connected session until the WebSocket closes."""
|
|
119
|
+
url = f"{ws_url}?token={token}"
|
|
120
|
+
# Use certifi CA bundle for SSL so WSS works on macOS without the
|
|
121
|
+
# 'Install Certificates.command' workaround.
|
|
122
|
+
# Protocol-level pings disabled — the server sends application-level
|
|
123
|
+
# JSON pings every 20 s to keep the connection alive (cli_api/router.py).
|
|
124
|
+
ssl_ctx = _ssl_context() if ws_url.startswith("wss://") else None
|
|
125
|
+
async with websockets.connect(url, ping_interval=None, ssl=ssl_ctx) as ws:
|
|
126
|
+
async for raw in ws:
|
|
127
|
+
try:
|
|
128
|
+
frame = json.loads(raw)
|
|
129
|
+
await _handle_frame(frame, workdir, ws)
|
|
130
|
+
except SystemExit:
|
|
131
|
+
raise
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.warning("Frame handling error: %s", exc)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def run_forever(server_url: str, token: str, workdir: Path) -> None:
|
|
137
|
+
"""Connect with exponential backoff, reconnecting on drop."""
|
|
138
|
+
ws_url = server_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
|
|
139
|
+
ws_url = f"{ws_url}/api/cli/ws"
|
|
140
|
+
|
|
141
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
console.print(f"Connecting to [bold]{server_url}[/bold] …")
|
|
143
|
+
|
|
144
|
+
attempt = 0
|
|
145
|
+
while True:
|
|
146
|
+
try:
|
|
147
|
+
await run_session(ws_url, token, workdir)
|
|
148
|
+
attempt = 0 # successful session resets backoff
|
|
149
|
+
except SystemExit:
|
|
150
|
+
return
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
delay = _RECONNECT_DELAYS[min(attempt, len(_RECONNECT_DELAYS) - 1)]
|
|
153
|
+
console.print(f"[red]Disconnected:[/red] {exc}. Retrying in {delay}s …")
|
|
154
|
+
await asyncio.sleep(delay)
|
|
155
|
+
attempt += 1
|
virtuai_cli/security.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Security helpers for the local sandbox executor.
|
|
2
|
+
|
|
3
|
+
These run on the CLI side before forwarding a command to the local shell.
|
|
4
|
+
The goal is a conservative default posture with documented trade-offs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Workdir jail
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def resolve_workdir(base: str) -> Path:
|
|
19
|
+
return Path(base).expanduser().resolve()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def jail_path(path: str, workdir: Path) -> Optional[Path]:
|
|
23
|
+
"""Return an absolute path only if it stays within workdir, else None."""
|
|
24
|
+
resolved = (workdir / path).resolve()
|
|
25
|
+
try:
|
|
26
|
+
resolved.relative_to(workdir)
|
|
27
|
+
return resolved
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Command denylist
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_DENYLIST: list[tuple[str, str]] = [
|
|
37
|
+
(r"\brm\s+-[rRf]*f[rRf]*\s+/\b", "rm -rf /"),
|
|
38
|
+
(r":\(\)\s*\{.*\|.*&\s*\}", "fork bomb"),
|
|
39
|
+
(r"\bmkfs\b", "mkfs"),
|
|
40
|
+
(r"\bdd\s+.*of=/dev/", "dd to device"),
|
|
41
|
+
(r"\bsudo\b", "sudo"),
|
|
42
|
+
(r"\bdoas\b", "doas"),
|
|
43
|
+
(r"\bpkexec\b", "pkexec"),
|
|
44
|
+
(r"\bchmod\s+777\s+/\b", "chmod 777 /"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_DENYLIST_RE = [(re.compile(pattern, re.IGNORECASE), label) for pattern, label in _DENYLIST]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_command(command: str) -> Optional[str]:
|
|
51
|
+
"""Return a denial reason if the command matches the denylist, else None."""
|
|
52
|
+
for pattern, label in _DENYLIST_RE:
|
|
53
|
+
if pattern.search(command):
|
|
54
|
+
return label
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Environment scrubbing
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_PASS_THROUGH_ENV = {
|
|
63
|
+
"PATH", "LANG", "TERM", "HOME", "SHELL",
|
|
64
|
+
"TMPDIR", "TMP", "TEMP",
|
|
65
|
+
# Some build tools need these
|
|
66
|
+
"USER", "LOGNAME",
|
|
67
|
+
"XDG_RUNTIME_DIR",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_VIRTUAI_ENV_PREFIX = "VIRTUAI_"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scrub_env(workdir: Path, extra_allow: list[str] | None = None) -> dict[str, str]:
|
|
74
|
+
"""Build a clean environment: allowlist + VIRTUAI_ vars + user opt-ins."""
|
|
75
|
+
allowed = set(_PASS_THROUGH_ENV)
|
|
76
|
+
if extra_allow:
|
|
77
|
+
allowed.update(extra_allow)
|
|
78
|
+
|
|
79
|
+
env: dict[str, str] = {}
|
|
80
|
+
for key, value in os.environ.items():
|
|
81
|
+
if key in allowed or key.startswith(_VIRTUAI_ENV_PREFIX):
|
|
82
|
+
env[key] = value
|
|
83
|
+
|
|
84
|
+
# Keep REAL_HOME so the agent can resolve user paths, but override HOME
|
|
85
|
+
# so tools that write to ~/.cache/~/.config don't escape the workdir
|
|
86
|
+
env["REAL_HOME"] = env.get("HOME", os.path.expanduser("~"))
|
|
87
|
+
env["HOME"] = str(workdir)
|
|
88
|
+
return env
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: virtuai-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run VirtuAI deep agents on your local machine
|
|
5
|
+
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://imvituai.com
|
|
8
|
+
Keywords: virtuai,ai,agents,cli
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: websockets>=12.0
|
|
16
|
+
Requires-Dist: httpx[http2]>=0.27
|
|
17
|
+
Requires-Dist: certifi>=2024.0
|
|
18
|
+
Requires-Dist: keyring>=25.0
|
|
19
|
+
Requires-Dist: typer>=0.12
|
|
20
|
+
Requires-Dist: rich>=13.0
|
|
21
|
+
|
|
22
|
+
# VirtuAI CLI
|
|
23
|
+
|
|
24
|
+
Run VirtuAI deep agents on your local machine.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install virtuai-cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.11+.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### 1. Pair your machine
|
|
37
|
+
|
|
38
|
+
Open the VirtuAI portal, go to **Settings → CLI**, generate a pairing code, then run:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
virtuai pair <CODE>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Start the local runner
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
virtuai run
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keep this terminal open while using VirtuAI. The agent can now execute shell commands on your machine.
|
|
51
|
+
|
|
52
|
+
### 3. Stop
|
|
53
|
+
|
|
54
|
+
Press `Ctrl+C` to stop the runner. Your workspace pairing persists.
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | Description |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `virtuai pair <code>` | Pair this machine with a VirtuAI workspace |
|
|
61
|
+
| `virtuai run` | Start the local runner |
|
|
62
|
+
| `virtuai status` | Show pairing and connection status |
|
|
63
|
+
| `virtuai logs` | Show recent commands executed by the agent |
|
|
64
|
+
| `virtuai unpair` | Revoke the current pairing |
|
|
65
|
+
| `virtuai login` | Authenticate with your VirtuAI account (browser flow) |
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
virtuai run --workdir /path/to/dir # Set a custom working directory
|
|
71
|
+
virtuai run --server https://... # Connect to a custom VirtuAI server
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Security
|
|
75
|
+
|
|
76
|
+
- Commands run under your local user account with your file system permissions.
|
|
77
|
+
- All communication is over encrypted WebSocket (WSS).
|
|
78
|
+
- The agent can only run when `virtuai run` is active — no background daemon.
|
|
79
|
+
- An audit log of executed commands is written to `~/.virtuai/audit.log`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
virtuai_cli/__init__.py,sha256=WAcpXcqSfKeyt4mWbJBLFeoZnp9eXKxNa6b-hoU3TAk,47
|
|
2
|
+
virtuai_cli/config.py,sha256=9VVbjeCkx2j2hJ4C4_PbWDN8xYe21FUv5OSyoxQkS3s,2911
|
|
3
|
+
virtuai_cli/executor.py,sha256=iK8K9rV7zGGJj6X_US4VKVGDSQIHOsYUJ3sDVQsbUCM,2946
|
|
4
|
+
virtuai_cli/main.py,sha256=hpM33n_wgSP6YgHnAOnelpJ-w5kjMZ0lFOin91Tbq4k,8271
|
|
5
|
+
virtuai_cli/runner.py,sha256=50sHy3ECevzyX5gyXmyH1rWLRhGORjWRFFRfoeZRhc0,5490
|
|
6
|
+
virtuai_cli/security.py,sha256=rWeGF4CyqoXN6o8DPrw2WTSBMv0gkbhtpTo5hlbF0OM,2805
|
|
7
|
+
virtuai_cli-0.1.0.dist-info/METADATA,sha256=1XnbxiE9uRvYUK4o47eFd2FEjX2t_MXw2uCnR1zqKUM,2126
|
|
8
|
+
virtuai_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
virtuai_cli-0.1.0.dist-info/entry_points.txt,sha256=upSvW1pH9YVChKODbByxiYqdZ4gqa14wYagEeWhfQzQ,49
|
|
10
|
+
virtuai_cli-0.1.0.dist-info/top_level.txt,sha256=mTSpGaMcoXDPdkEWKezmgAPNFziWSi2NiQAvsQ8YiD8,12
|
|
11
|
+
virtuai_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
virtuai_cli
|