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.
@@ -0,0 +1,2 @@
1
+ """VirtuAI local CLI."""
2
+ __version__ = "0.1.0"
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
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ virtuai = virtuai_cli.main:app
@@ -0,0 +1 @@
1
+ virtuai_cli