virtuai-cli 0.7.6__tar.gz → 0.8.0__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.
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/PKG-INFO +34 -8
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/README.md +33 -7
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/pyproject.toml +1 -1
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/__init__.py +1 -1
- virtuai_cli-0.8.0/src/virtuai_cli/chat/sse.py +138 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/tui.py +132 -9
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/PKG-INFO +34 -8
- virtuai_cli-0.7.6/src/virtuai_cli/chat/sse.py +0 -63
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/setup.cfg +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/__init__.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/ask.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/command.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/history.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/chat/widgets.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/config.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/executor.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/main.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/runner.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli/security.py +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
- {virtuai_cli-0.7.6 → virtuai_cli-0.8.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -152,13 +152,39 @@ Exit codes: `0` success · `1` stream error · `2` bad args / agent not found.
|
|
|
152
152
|
|
|
153
153
|
## Security model
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
-
|
|
155
|
+
**Read this section before pointing an agent at anything you care about.**
|
|
156
|
+
|
|
157
|
+
The CLI runs the agent's shell commands **as your user account**, with your full filesystem permissions. There is no sandbox. The agent can do anything you can do at the shell. The guardrails below are best described as *speed bumps*, not walls.
|
|
158
|
+
|
|
159
|
+
### What is enforced
|
|
160
|
+
|
|
161
|
+
- **Auth**: CLI token is stored in your OS keychain (Keychain on macOS, libsecret on Linux, Credential Manager on Windows). All traffic is TLS (WSS + HTTPS) with the [`certifi`](https://github.com/certifi/python-certifi) CA bundle.
|
|
162
|
+
- **File-tool path jail**: the deepagents file tools — `write_file`, `edit_file`, `read_file` — route through `upload_files`/`download_files`, which reject absolute paths outside the workdir. This part works.
|
|
163
|
+
- **`cd` jail in bash**: the executor wraps every shell command in a bash subshell with a custom `cd` function that refuses to leave the workdir. Catches the trivial `cd ..` case.
|
|
164
|
+
- **Denylist**: a regex match on the command string blocks `sudo`, `rm -rf /`, fork bombs, `mkfs`, `dd of=/dev/...`. Easy to bypass with aliasing or encoding; mostly there to stop drive-by accidents.
|
|
165
|
+
- **Audit log**: every executed command + exit code goes to `~/.virtuai/audit.log` with a timestamp.
|
|
166
|
+
- **Foreground-only**: the agent only has access while `virtuai chat`, `virtuai ask`, or `virtuai run` is running — no background daemon.
|
|
167
|
+
|
|
168
|
+
### What is NOT enforced
|
|
169
|
+
|
|
170
|
+
- **Shell commands can use absolute paths anywhere**. The `cd` jail only intercepts `cd`. `cat /etc/passwd`, `ls /Users/you/Documents`, `rm /tmp/whatever` — none of these touch `cd`, so none are blocked. **The agent can read, write, and delete anything you can.**
|
|
171
|
+
- The `cd` jail itself is **bash-only**. `sh -c '...'`, `python -c "os.chdir('/')"`, `node -e "..."` all bypass it.
|
|
172
|
+
- **Command substitution, eval, env vars, aliases** all defeat the denylist. `$(echo s)$(echo udo) ...` looks nothing like `sudo` to a regex.
|
|
173
|
+
- **Network egress is not restricted.** The agent can `curl`/`wget` anywhere, exfiltrate to any host.
|
|
174
|
+
|
|
175
|
+
### If you need real isolation
|
|
176
|
+
|
|
177
|
+
The only honest options:
|
|
178
|
+
|
|
179
|
+
1. **Run the CLI inside a container** with the workdir bind-mounted and nothing else:
|
|
180
|
+
```bash
|
|
181
|
+
docker run --rm -it -v "$PWD:/work" -w /work python:3.12 bash -c "pip install virtuai-cli && virtuai pair <code> && virtuai chat"
|
|
182
|
+
```
|
|
183
|
+
The container has access only to what you mount.
|
|
184
|
+
2. **OS-level sandboxing**: wrap `virtuai run` in `sandbox-exec` on macOS or run inside a `landlock`/`bubblewrap` jail on Linux.
|
|
185
|
+
3. **Switch the agent's backend to the E2B remote sandbox** in the portal. The agent then runs against a disposable VM, not your laptop. The CLI runner becomes optional (you'd only need it for "interactive" workflows where local files matter).
|
|
186
|
+
|
|
187
|
+
**Use the CLI runner the same way you'd use a powerful shell session you don't fully trust.** Point it at a project directory, never at `~`, and don't run unattended on a machine with credentials lying around.
|
|
162
188
|
|
|
163
189
|
## Programmatic use
|
|
164
190
|
|
|
@@ -129,13 +129,39 @@ Exit codes: `0` success · `1` stream error · `2` bad args / agent not found.
|
|
|
129
129
|
|
|
130
130
|
## Security model
|
|
131
131
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
-
|
|
132
|
+
**Read this section before pointing an agent at anything you care about.**
|
|
133
|
+
|
|
134
|
+
The CLI runs the agent's shell commands **as your user account**, with your full filesystem permissions. There is no sandbox. The agent can do anything you can do at the shell. The guardrails below are best described as *speed bumps*, not walls.
|
|
135
|
+
|
|
136
|
+
### What is enforced
|
|
137
|
+
|
|
138
|
+
- **Auth**: CLI token is stored in your OS keychain (Keychain on macOS, libsecret on Linux, Credential Manager on Windows). All traffic is TLS (WSS + HTTPS) with the [`certifi`](https://github.com/certifi/python-certifi) CA bundle.
|
|
139
|
+
- **File-tool path jail**: the deepagents file tools — `write_file`, `edit_file`, `read_file` — route through `upload_files`/`download_files`, which reject absolute paths outside the workdir. This part works.
|
|
140
|
+
- **`cd` jail in bash**: the executor wraps every shell command in a bash subshell with a custom `cd` function that refuses to leave the workdir. Catches the trivial `cd ..` case.
|
|
141
|
+
- **Denylist**: a regex match on the command string blocks `sudo`, `rm -rf /`, fork bombs, `mkfs`, `dd of=/dev/...`. Easy to bypass with aliasing or encoding; mostly there to stop drive-by accidents.
|
|
142
|
+
- **Audit log**: every executed command + exit code goes to `~/.virtuai/audit.log` with a timestamp.
|
|
143
|
+
- **Foreground-only**: the agent only has access while `virtuai chat`, `virtuai ask`, or `virtuai run` is running — no background daemon.
|
|
144
|
+
|
|
145
|
+
### What is NOT enforced
|
|
146
|
+
|
|
147
|
+
- **Shell commands can use absolute paths anywhere**. The `cd` jail only intercepts `cd`. `cat /etc/passwd`, `ls /Users/you/Documents`, `rm /tmp/whatever` — none of these touch `cd`, so none are blocked. **The agent can read, write, and delete anything you can.**
|
|
148
|
+
- The `cd` jail itself is **bash-only**. `sh -c '...'`, `python -c "os.chdir('/')"`, `node -e "..."` all bypass it.
|
|
149
|
+
- **Command substitution, eval, env vars, aliases** all defeat the denylist. `$(echo s)$(echo udo) ...` looks nothing like `sudo` to a regex.
|
|
150
|
+
- **Network egress is not restricted.** The agent can `curl`/`wget` anywhere, exfiltrate to any host.
|
|
151
|
+
|
|
152
|
+
### If you need real isolation
|
|
153
|
+
|
|
154
|
+
The only honest options:
|
|
155
|
+
|
|
156
|
+
1. **Run the CLI inside a container** with the workdir bind-mounted and nothing else:
|
|
157
|
+
```bash
|
|
158
|
+
docker run --rm -it -v "$PWD:/work" -w /work python:3.12 bash -c "pip install virtuai-cli && virtuai pair <code> && virtuai chat"
|
|
159
|
+
```
|
|
160
|
+
The container has access only to what you mount.
|
|
161
|
+
2. **OS-level sandboxing**: wrap `virtuai run` in `sandbox-exec` on macOS or run inside a `landlock`/`bubblewrap` jail on Linux.
|
|
162
|
+
3. **Switch the agent's backend to the E2B remote sandbox** in the portal. The agent then runs against a disposable VM, not your laptop. The CLI runner becomes optional (you'd only need it for "interactive" workflows where local files matter).
|
|
163
|
+
|
|
164
|
+
**Use the CLI runner the same way you'd use a powerful shell session you don't fully trust.** Point it at a project directory, never at `~`, and don't run unattended on a machine with credentials lying around.
|
|
139
165
|
|
|
140
166
|
## Programmatic use
|
|
141
167
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""VirtuAI local CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.8.0"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Async SSE chat client — POSTs a user message and yields parsed events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import ssl
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import AsyncIterator, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
import certifi
|
|
12
|
+
import httpx
|
|
13
|
+
from httpx_sse import aconnect_sse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
17
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def stream_chat(
|
|
21
|
+
server_url: str,
|
|
22
|
+
token: str,
|
|
23
|
+
agent_id: str,
|
|
24
|
+
message: str,
|
|
25
|
+
session_id: Optional[str] = None,
|
|
26
|
+
model_id: Optional[str] = None,
|
|
27
|
+
timeout: float = 600.0,
|
|
28
|
+
) -> AsyncIterator[dict]:
|
|
29
|
+
"""Open an SSE chat stream and yield each event as a parsed dict.
|
|
30
|
+
|
|
31
|
+
The CLI token is passed as `Authorization: Bearer <token>`; the chat
|
|
32
|
+
principal now accepts CLI tokens (see web_chat/deps.py).
|
|
33
|
+
"""
|
|
34
|
+
payload: dict = {"message": message}
|
|
35
|
+
if session_id:
|
|
36
|
+
payload["session_id"] = session_id
|
|
37
|
+
if model_id:
|
|
38
|
+
payload["model_id"] = model_id
|
|
39
|
+
|
|
40
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
41
|
+
url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/stream"
|
|
42
|
+
verify = certifi.where()
|
|
43
|
+
|
|
44
|
+
async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
|
|
45
|
+
async with aconnect_sse(client, "POST", url, json=payload, headers=headers) as event_source:
|
|
46
|
+
async for event in _iter_normalized_sse(event_source):
|
|
47
|
+
yield event
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Mirror of frontend/src/utils/webChatMultimodalCatalog.ts
|
|
51
|
+
WEBCHAT_MAX_FILES = 3
|
|
52
|
+
WEBCHAT_MAX_FILE_SIZE_MB = 10
|
|
53
|
+
WEBCHAT_ALLOWED_EXTENSIONS = {
|
|
54
|
+
".jpg", ".jpeg", ".png", ".gif", ".webp",
|
|
55
|
+
".pdf",
|
|
56
|
+
".txt", ".csv", ".md",
|
|
57
|
+
".docx", ".xlsx", ".pptx",
|
|
58
|
+
}
|
|
59
|
+
_EXTRA_MIMES = {
|
|
60
|
+
".md": "text/markdown",
|
|
61
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
62
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
63
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def guess_mime(path: Path) -> str:
|
|
68
|
+
"""Return a best-effort MIME type for an attachment path.
|
|
69
|
+
|
|
70
|
+
Falls back to a small extra map for office types and markdown that
|
|
71
|
+
Python's mimetypes module doesn't ship reliably across platforms.
|
|
72
|
+
"""
|
|
73
|
+
suffix = path.suffix.lower()
|
|
74
|
+
if suffix in _EXTRA_MIMES:
|
|
75
|
+
return _EXTRA_MIMES[suffix]
|
|
76
|
+
mime, _ = mimetypes.guess_type(str(path))
|
|
77
|
+
return mime or "application/octet-stream"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def stream_chat_multimodal(
|
|
81
|
+
server_url: str,
|
|
82
|
+
token: str,
|
|
83
|
+
agent_id: str,
|
|
84
|
+
message: str,
|
|
85
|
+
files: Sequence[Path],
|
|
86
|
+
session_id: Optional[str] = None,
|
|
87
|
+
model_id: Optional[str] = None,
|
|
88
|
+
timeout: float = 600.0,
|
|
89
|
+
) -> AsyncIterator[dict]:
|
|
90
|
+
"""Like stream_chat() but POSTs to the multimodal endpoint with attachments.
|
|
91
|
+
|
|
92
|
+
The web chat's multimodal stream accepts up to WEBCHAT_MAX_FILES files of
|
|
93
|
+
the types listed in WEBCHAT_ALLOWED_EXTENSIONS; the server extracts text
|
|
94
|
+
from PDFs/Office docs and feeds it (or the raw image bytes) to the model.
|
|
95
|
+
"""
|
|
96
|
+
data: dict = {"message": message}
|
|
97
|
+
if session_id:
|
|
98
|
+
data["session_id"] = session_id
|
|
99
|
+
if model_id:
|
|
100
|
+
data["model_id"] = model_id
|
|
101
|
+
|
|
102
|
+
file_uploads = []
|
|
103
|
+
for path in files:
|
|
104
|
+
try:
|
|
105
|
+
content = path.read_bytes()
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise FileNotFoundError(f"can't read {path}: {exc}") from exc
|
|
108
|
+
file_uploads.append(("files", (path.name, content, guess_mime(path))))
|
|
109
|
+
|
|
110
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
111
|
+
url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/multimodal/stream"
|
|
112
|
+
verify = certifi.where()
|
|
113
|
+
|
|
114
|
+
async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
|
|
115
|
+
async with aconnect_sse(
|
|
116
|
+
client, "POST", url, data=data, files=file_uploads, headers=headers
|
|
117
|
+
) as event_source:
|
|
118
|
+
async for event in _iter_normalized_sse(event_source):
|
|
119
|
+
yield event
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _iter_normalized_sse(event_source) -> AsyncIterator[dict]:
|
|
123
|
+
"""Shared SSE-to-typed-event loop for both stream_chat variants."""
|
|
124
|
+
async for sse in event_source.aiter_sse():
|
|
125
|
+
if not sse.data:
|
|
126
|
+
continue
|
|
127
|
+
if sse.data.strip() == "[DONE]":
|
|
128
|
+
yield {"type": "done"}
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
parsed = json.loads(sse.data)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
continue
|
|
134
|
+
if isinstance(parsed, dict):
|
|
135
|
+
yield parsed
|
|
136
|
+
elif isinstance(parsed, str):
|
|
137
|
+
# Classic agents stream tokens as bare JSON strings.
|
|
138
|
+
yield {"type": "token", "content": parsed}
|
|
@@ -16,7 +16,13 @@ from textual.widgets import Footer, Header, Static, TextArea
|
|
|
16
16
|
|
|
17
17
|
from virtuai_cli import runner as ws_runner
|
|
18
18
|
from virtuai_cli.chat.history import list_conversations, load_conversation
|
|
19
|
-
from virtuai_cli.chat.sse import
|
|
19
|
+
from virtuai_cli.chat.sse import (
|
|
20
|
+
WEBCHAT_ALLOWED_EXTENSIONS,
|
|
21
|
+
WEBCHAT_MAX_FILES,
|
|
22
|
+
WEBCHAT_MAX_FILE_SIZE_MB,
|
|
23
|
+
stream_chat,
|
|
24
|
+
stream_chat_multimodal,
|
|
25
|
+
)
|
|
20
26
|
from virtuai_cli.chat.widgets import AssistantTurn, ChatInput, TextSegment, UserBubble
|
|
21
27
|
|
|
22
28
|
|
|
@@ -28,6 +34,9 @@ SLASH_COMMANDS: list[tuple[str, str]] = [
|
|
|
28
34
|
("/new", "alias for /clear"),
|
|
29
35
|
("/copy", "copy the last assistant response to the clipboard"),
|
|
30
36
|
("/select", "toggle terminal-native text selection (also F2)"),
|
|
37
|
+
("/attach", "attach a file to your next message: /attach <path>"),
|
|
38
|
+
("/attachments", "list pending attachments"),
|
|
39
|
+
("/detach", "remove an attachment by name or index: /detach <n|name>"),
|
|
31
40
|
("/history", "list this agent's recent conversations"),
|
|
32
41
|
("/load", "load a past conversation: /load <session_id>"),
|
|
33
42
|
("/models", "list models available for this agent"),
|
|
@@ -73,7 +82,10 @@ class ChatApp(App):
|
|
|
73
82
|
"""
|
|
74
83
|
|
|
75
84
|
BINDINGS = [
|
|
76
|
-
|
|
85
|
+
# priority=True so Esc always cancels the stream even when the
|
|
86
|
+
# TextArea-based ChatInput has focus — TextArea itself doesn't bind
|
|
87
|
+
# Esc, but priority makes the guarantee explicit.
|
|
88
|
+
Binding("escape", "cancel_stream", "Cancel", show=True, priority=True),
|
|
77
89
|
Binding("ctrl+c", "quit", "Quit", show=True),
|
|
78
90
|
Binding("ctrl+l", "clear_conversation", "New chat", show=True),
|
|
79
91
|
Binding("ctrl+y", "copy_last", "Copy last", show=True, priority=True),
|
|
@@ -116,6 +128,9 @@ class ChatApp(App):
|
|
|
116
128
|
self._streaming: bool = False
|
|
117
129
|
self._status_message: str = ""
|
|
118
130
|
self._spinner_idx: int = 0
|
|
131
|
+
# Pending attachments to include with the next message — flipped to the
|
|
132
|
+
# multimodal endpoint when non-empty, cleared after a successful send.
|
|
133
|
+
self._pending_attachments: list[Path] = []
|
|
119
134
|
|
|
120
135
|
# ── Layout ────────────────────────────────────────────────────────────
|
|
121
136
|
def compose(self) -> ComposeResult:
|
|
@@ -313,6 +328,9 @@ class ChatApp(App):
|
|
|
313
328
|
return
|
|
314
329
|
|
|
315
330
|
await self._append(UserBubble(text))
|
|
331
|
+
if self._pending_attachments:
|
|
332
|
+
preview = " · ".join(f"📎 {p.name}" for p in self._pending_attachments)
|
|
333
|
+
await self._append(Static(f" [dim]{preview}[/dim]"))
|
|
316
334
|
turn = AssistantTurn()
|
|
317
335
|
await self._append(turn)
|
|
318
336
|
self._current_turn = turn
|
|
@@ -347,6 +365,12 @@ class ChatApp(App):
|
|
|
347
365
|
self.action_copy_last()
|
|
348
366
|
elif head == "/select":
|
|
349
367
|
self.action_toggle_select_mode()
|
|
368
|
+
elif head == "/attach":
|
|
369
|
+
await self._attach_file(arg)
|
|
370
|
+
elif head == "/attachments":
|
|
371
|
+
await self._show_attachments()
|
|
372
|
+
elif head == "/detach":
|
|
373
|
+
await self._detach_file(arg)
|
|
350
374
|
elif head == "/help":
|
|
351
375
|
await self._append(Static(
|
|
352
376
|
"[b]Commands[/b]\n"
|
|
@@ -354,6 +378,9 @@ class ChatApp(App):
|
|
|
354
378
|
" /clear, /new start a fresh conversation\n"
|
|
355
379
|
" /copy copy the last assistant response to clipboard\n"
|
|
356
380
|
" /select toggle terminal-native text selection\n"
|
|
381
|
+
" /attach <path> attach a file to your next message\n"
|
|
382
|
+
" /attachments list pending attachments\n"
|
|
383
|
+
" /detach <n|name> remove an attachment (or /detach to clear all)\n"
|
|
357
384
|
" /history list this agent's recent conversations\n"
|
|
358
385
|
" /load <id> load a past conversation by session_id\n"
|
|
359
386
|
" /models list models available for this agent\n"
|
|
@@ -393,6 +420,90 @@ class ChatApp(App):
|
|
|
393
420
|
lines.append("\n[dim]Use [/dim][b]/model <id>[/b][dim] to switch.[/dim]")
|
|
394
421
|
await self._append(Static("\n".join(lines)))
|
|
395
422
|
|
|
423
|
+
async def _attach_file(self, arg: str) -> None:
|
|
424
|
+
if not arg:
|
|
425
|
+
await self._append(Static(
|
|
426
|
+
"Usage: [b]/attach <path>[/b]\n"
|
|
427
|
+
f"[dim]Allowed: {', '.join(sorted(WEBCHAT_ALLOWED_EXTENSIONS))}\n"
|
|
428
|
+
f"Limit: {WEBCHAT_MAX_FILES} files per message, "
|
|
429
|
+
f"{WEBCHAT_MAX_FILE_SIZE_MB} MB each.[/dim]"
|
|
430
|
+
))
|
|
431
|
+
return
|
|
432
|
+
# Trim surrounding quotes and expand ~/$HOME
|
|
433
|
+
raw = arg.strip().strip('"').strip("'")
|
|
434
|
+
path = Path(raw).expanduser()
|
|
435
|
+
try:
|
|
436
|
+
path = path.resolve(strict=True)
|
|
437
|
+
except (FileNotFoundError, OSError) as exc:
|
|
438
|
+
await self._append(Static(f"[red]File not found:[/red] {raw} ({exc})"))
|
|
439
|
+
return
|
|
440
|
+
if not path.is_file():
|
|
441
|
+
await self._append(Static(f"[red]Not a file:[/red] {path}"))
|
|
442
|
+
return
|
|
443
|
+
if path.suffix.lower() not in WEBCHAT_ALLOWED_EXTENSIONS:
|
|
444
|
+
await self._append(Static(
|
|
445
|
+
f"[red]Unsupported file type:[/red] {path.suffix or '(no extension)'}\n"
|
|
446
|
+
f"[dim]Allowed: {', '.join(sorted(WEBCHAT_ALLOWED_EXTENSIONS))}[/dim]"
|
|
447
|
+
))
|
|
448
|
+
return
|
|
449
|
+
size_mb = path.stat().st_size / (1024 * 1024)
|
|
450
|
+
if size_mb > WEBCHAT_MAX_FILE_SIZE_MB:
|
|
451
|
+
await self._append(Static(
|
|
452
|
+
f"[red]File too large:[/red] {size_mb:.1f} MB "
|
|
453
|
+
f"(limit {WEBCHAT_MAX_FILE_SIZE_MB} MB)"
|
|
454
|
+
))
|
|
455
|
+
return
|
|
456
|
+
if len(self._pending_attachments) >= WEBCHAT_MAX_FILES:
|
|
457
|
+
await self._append(Static(
|
|
458
|
+
f"[yellow]Already at the {WEBCHAT_MAX_FILES}-file limit.[/yellow] "
|
|
459
|
+
f"Use [b]/detach <n>[/b] to drop one."
|
|
460
|
+
))
|
|
461
|
+
return
|
|
462
|
+
if any(p == path for p in self._pending_attachments):
|
|
463
|
+
await self._append(Static(f"[yellow]Already attached:[/yellow] {path.name}"))
|
|
464
|
+
return
|
|
465
|
+
self._pending_attachments.append(path)
|
|
466
|
+
await self._append(Static(
|
|
467
|
+
f"[green]📎 Attached:[/green] [b]{path.name}[/b] "
|
|
468
|
+
f"[dim]({size_mb:.1f} MB · {path})[/dim]\n"
|
|
469
|
+
f"[dim]{len(self._pending_attachments)}/{WEBCHAT_MAX_FILES} files pending. "
|
|
470
|
+
f"Send your message normally; the agent will see them.[/dim]"
|
|
471
|
+
))
|
|
472
|
+
|
|
473
|
+
async def _show_attachments(self) -> None:
|
|
474
|
+
if not self._pending_attachments:
|
|
475
|
+
await self._append(Static("[dim]No attachments pending.[/dim]"))
|
|
476
|
+
return
|
|
477
|
+
lines = [f"[b]Pending attachments[/b] [dim](sent with your next message)[/dim]"]
|
|
478
|
+
for i, path in enumerate(self._pending_attachments, start=1):
|
|
479
|
+
size_mb = path.stat().st_size / (1024 * 1024)
|
|
480
|
+
lines.append(f" {i}. [b]{path.name}[/b] [dim]({size_mb:.1f} MB · {path})[/dim]")
|
|
481
|
+
lines.append("\n[dim]Drop one with [/dim][b]/detach <n>[/b]")
|
|
482
|
+
await self._append(Static("\n".join(lines)))
|
|
483
|
+
|
|
484
|
+
async def _detach_file(self, arg: str) -> None:
|
|
485
|
+
if not self._pending_attachments:
|
|
486
|
+
await self._append(Static("[dim]No attachments to detach.[/dim]"))
|
|
487
|
+
return
|
|
488
|
+
if not arg:
|
|
489
|
+
self._pending_attachments.clear()
|
|
490
|
+
await self._append(Static("[yellow]Cleared all pending attachments.[/yellow]"))
|
|
491
|
+
return
|
|
492
|
+
# By index (1-based)
|
|
493
|
+
if arg.isdigit():
|
|
494
|
+
idx = int(arg) - 1
|
|
495
|
+
if 0 <= idx < len(self._pending_attachments):
|
|
496
|
+
removed = self._pending_attachments.pop(idx)
|
|
497
|
+
await self._append(Static(f"[yellow]Detached:[/yellow] {removed.name}"))
|
|
498
|
+
return
|
|
499
|
+
# By filename
|
|
500
|
+
for p in list(self._pending_attachments):
|
|
501
|
+
if p.name == arg or str(p) == arg:
|
|
502
|
+
self._pending_attachments.remove(p)
|
|
503
|
+
await self._append(Static(f"[yellow]Detached:[/yellow] {p.name}"))
|
|
504
|
+
return
|
|
505
|
+
await self._append(Static(f"[red]No match for:[/red] {arg}"))
|
|
506
|
+
|
|
396
507
|
async def _show_history(self) -> None:
|
|
397
508
|
try:
|
|
398
509
|
convs = await list_conversations(self.server_url, self.token, self.agent_id, limit=20)
|
|
@@ -607,15 +718,27 @@ class ChatApp(App):
|
|
|
607
718
|
async def _stream_response(self, message: str, turn: AssistantTurn) -> None:
|
|
608
719
|
self._streaming = True
|
|
609
720
|
self._refresh_status_bar()
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
721
|
+
# Snapshot + clear pending attachments BEFORE the request so a new
|
|
722
|
+
# /attach during streaming doesn't bleed into this turn or the next.
|
|
723
|
+
attachments = list(self._pending_attachments)
|
|
724
|
+
self._pending_attachments.clear()
|
|
725
|
+
|
|
726
|
+
if attachments:
|
|
727
|
+
stream_iter = stream_chat_multimodal(
|
|
728
|
+
self.server_url, self.token, self.agent_id, message,
|
|
729
|
+
files=attachments,
|
|
730
|
+
session_id=self.session_id,
|
|
731
|
+
model_id=self.current_model_id,
|
|
732
|
+
)
|
|
733
|
+
else:
|
|
734
|
+
stream_iter = stream_chat(
|
|
735
|
+
self.server_url, self.token, self.agent_id, message,
|
|
616
736
|
session_id=self.session_id,
|
|
617
737
|
model_id=self.current_model_id,
|
|
618
|
-
)
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
async for event in stream_iter:
|
|
619
742
|
await self._handle_event(event, turn)
|
|
620
743
|
self._follow_bottom()
|
|
621
744
|
except asyncio.CancelledError:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: virtuai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Run VirtuAI deep agents on your local machine
|
|
5
5
|
Author-email: uCloudStore <lmoreno@ucloudstore.com>
|
|
6
6
|
License: Proprietary
|
|
@@ -152,13 +152,39 @@ Exit codes: `0` success · `1` stream error · `2` bad args / agent not found.
|
|
|
152
152
|
|
|
153
153
|
## Security model
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
-
|
|
155
|
+
**Read this section before pointing an agent at anything you care about.**
|
|
156
|
+
|
|
157
|
+
The CLI runs the agent's shell commands **as your user account**, with your full filesystem permissions. There is no sandbox. The agent can do anything you can do at the shell. The guardrails below are best described as *speed bumps*, not walls.
|
|
158
|
+
|
|
159
|
+
### What is enforced
|
|
160
|
+
|
|
161
|
+
- **Auth**: CLI token is stored in your OS keychain (Keychain on macOS, libsecret on Linux, Credential Manager on Windows). All traffic is TLS (WSS + HTTPS) with the [`certifi`](https://github.com/certifi/python-certifi) CA bundle.
|
|
162
|
+
- **File-tool path jail**: the deepagents file tools — `write_file`, `edit_file`, `read_file` — route through `upload_files`/`download_files`, which reject absolute paths outside the workdir. This part works.
|
|
163
|
+
- **`cd` jail in bash**: the executor wraps every shell command in a bash subshell with a custom `cd` function that refuses to leave the workdir. Catches the trivial `cd ..` case.
|
|
164
|
+
- **Denylist**: a regex match on the command string blocks `sudo`, `rm -rf /`, fork bombs, `mkfs`, `dd of=/dev/...`. Easy to bypass with aliasing or encoding; mostly there to stop drive-by accidents.
|
|
165
|
+
- **Audit log**: every executed command + exit code goes to `~/.virtuai/audit.log` with a timestamp.
|
|
166
|
+
- **Foreground-only**: the agent only has access while `virtuai chat`, `virtuai ask`, or `virtuai run` is running — no background daemon.
|
|
167
|
+
|
|
168
|
+
### What is NOT enforced
|
|
169
|
+
|
|
170
|
+
- **Shell commands can use absolute paths anywhere**. The `cd` jail only intercepts `cd`. `cat /etc/passwd`, `ls /Users/you/Documents`, `rm /tmp/whatever` — none of these touch `cd`, so none are blocked. **The agent can read, write, and delete anything you can.**
|
|
171
|
+
- The `cd` jail itself is **bash-only**. `sh -c '...'`, `python -c "os.chdir('/')"`, `node -e "..."` all bypass it.
|
|
172
|
+
- **Command substitution, eval, env vars, aliases** all defeat the denylist. `$(echo s)$(echo udo) ...` looks nothing like `sudo` to a regex.
|
|
173
|
+
- **Network egress is not restricted.** The agent can `curl`/`wget` anywhere, exfiltrate to any host.
|
|
174
|
+
|
|
175
|
+
### If you need real isolation
|
|
176
|
+
|
|
177
|
+
The only honest options:
|
|
178
|
+
|
|
179
|
+
1. **Run the CLI inside a container** with the workdir bind-mounted and nothing else:
|
|
180
|
+
```bash
|
|
181
|
+
docker run --rm -it -v "$PWD:/work" -w /work python:3.12 bash -c "pip install virtuai-cli && virtuai pair <code> && virtuai chat"
|
|
182
|
+
```
|
|
183
|
+
The container has access only to what you mount.
|
|
184
|
+
2. **OS-level sandboxing**: wrap `virtuai run` in `sandbox-exec` on macOS or run inside a `landlock`/`bubblewrap` jail on Linux.
|
|
185
|
+
3. **Switch the agent's backend to the E2B remote sandbox** in the portal. The agent then runs against a disposable VM, not your laptop. The CLI runner becomes optional (you'd only need it for "interactive" workflows where local files matter).
|
|
186
|
+
|
|
187
|
+
**Use the CLI runner the same way you'd use a powerful shell session you don't fully trust.** Point it at a project directory, never at `~`, and don't run unattended on a machine with credentials lying around.
|
|
162
188
|
|
|
163
189
|
## Programmatic use
|
|
164
190
|
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
"""Async SSE chat client — POSTs a user message and yields parsed events."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import ssl
|
|
7
|
-
from typing import AsyncIterator, Optional
|
|
8
|
-
|
|
9
|
-
import certifi
|
|
10
|
-
import httpx
|
|
11
|
-
from httpx_sse import aconnect_sse
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _ssl_context() -> ssl.SSLContext:
|
|
15
|
-
return ssl.create_default_context(cafile=certifi.where())
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
async def stream_chat(
|
|
19
|
-
server_url: str,
|
|
20
|
-
token: str,
|
|
21
|
-
agent_id: str,
|
|
22
|
-
message: str,
|
|
23
|
-
session_id: Optional[str] = None,
|
|
24
|
-
model_id: Optional[str] = None,
|
|
25
|
-
timeout: float = 600.0,
|
|
26
|
-
) -> AsyncIterator[dict]:
|
|
27
|
-
"""Open an SSE chat stream and yield each event as a parsed dict.
|
|
28
|
-
|
|
29
|
-
The CLI token is passed as `Authorization: Bearer <token>`; the chat
|
|
30
|
-
principal now accepts CLI tokens (see web_chat/deps.py).
|
|
31
|
-
"""
|
|
32
|
-
payload: dict = {"message": message}
|
|
33
|
-
if session_id:
|
|
34
|
-
payload["session_id"] = session_id
|
|
35
|
-
if model_id:
|
|
36
|
-
payload["model_id"] = model_id
|
|
37
|
-
|
|
38
|
-
headers = {"Authorization": f"Bearer {token}"}
|
|
39
|
-
url = f"{server_url.rstrip('/')}/api/web-chat/chat/{agent_id}/stream"
|
|
40
|
-
verify = certifi.where()
|
|
41
|
-
|
|
42
|
-
async with httpx.AsyncClient(verify=verify, timeout=httpx.Timeout(timeout, connect=15.0)) as client:
|
|
43
|
-
async with aconnect_sse(client, "POST", url, json=payload, headers=headers) as event_source:
|
|
44
|
-
async for sse in event_source.aiter_sse():
|
|
45
|
-
if not sse.data:
|
|
46
|
-
continue
|
|
47
|
-
# Classic-agent path emits `data: [DONE]` after the stream;
|
|
48
|
-
# treat that as a done event for symmetry with the deep path.
|
|
49
|
-
if sse.data.strip() == "[DONE]":
|
|
50
|
-
yield {"type": "done"}
|
|
51
|
-
continue
|
|
52
|
-
try:
|
|
53
|
-
parsed = json.loads(sse.data)
|
|
54
|
-
except json.JSONDecodeError:
|
|
55
|
-
continue
|
|
56
|
-
if isinstance(parsed, dict):
|
|
57
|
-
yield parsed
|
|
58
|
-
elif isinstance(parsed, str):
|
|
59
|
-
# Classic agents stream tokens as bare JSON strings
|
|
60
|
-
# (`data: "hello "`). Wrap them so downstream handlers
|
|
61
|
-
# don't need to know about the legacy protocol.
|
|
62
|
-
yield {"type": "token", "content": parsed}
|
|
63
|
-
# Other shapes (lists, ints, null) are dropped silently.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|