virtuai-cli 1.0.4__tar.gz → 1.0.6__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.
Files changed (39) hide show
  1. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/PKG-INFO +1 -1
  2. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/pyproject.toml +1 -1
  3. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/main.py +2 -2
  5. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/opencode.py +158 -50
  6. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/security.py +13 -0
  7. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  8. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli.egg-info/SOURCES.txt +1 -0
  9. virtuai_cli-1.0.6/tests/test_proxy_env.py +29 -0
  10. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/README.md +0 -0
  11. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/setup.cfg +0 -0
  12. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/browser.py +0 -0
  13. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/__init__.py +0 -0
  14. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/agents.py +0 -0
  15. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/ask.py +0 -0
  16. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/command.py +0 -0
  17. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/history.py +0 -0
  18. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/sse.py +0 -0
  19. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/tui.py +0 -0
  20. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/chat/widgets.py +0 -0
  21. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/config.py +0 -0
  22. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/executor.py +0 -0
  23. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/ollama_state.py +0 -0
  24. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/omlx_state.py +0 -0
  25. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/pull.py +0 -0
  26. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli/runner.py +0 -0
  27. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  28. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  29. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli.egg-info/requires.txt +0 -0
  30. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/src/virtuai_cli.egg-info/top_level.txt +0 -0
  31. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_browser.py +0 -0
  32. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_frame_concurrency.py +0 -0
  33. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_ollama_options.py +0 -0
  34. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_ollama_state.py +0 -0
  35. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_omlx.py +0 -0
  36. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_process_group_kill.py +0 -0
  37. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_pull_fallback.py +0 -0
  38. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_run_single_instance.py +0 -0
  39. {virtuai_cli-1.0.4 → virtuai_cli-1.0.6}/tests/test_session_isolation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "1.0.4"
7
+ version = "1.0.6"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "1.0.4"
2
+ __version__ = "1.0.6"
@@ -151,7 +151,7 @@ def unpair():
151
151
  cfg.delete_token(cfg._KEYRING_CLI_TOKEN_KEY)
152
152
  console.print("[yellow]Unpaired.[/yellow] CLI token cleared.")
153
153
  console.print(
154
- "[dim]If you ran virtuai opencode, remove provider.virtuai from "
154
+ "[dim]If you ran virtuai opencode, remove providers.virtuai from "
155
155
  "~/.config/opencode/opencode.json — that file still holds a copy of the token.[/dim]"
156
156
  )
157
157
 
@@ -476,7 +476,7 @@ def opencode(
476
476
  ):
477
477
  """Point the local OpenCode TUI at this workspace's VirtuAI agents.
478
478
 
479
- Copies the CLI pairing token into OpenCode as provider.virtuai.options.apiKey
479
+ Copies the CLI pairing token into OpenCode 2 as providers.virtuai.settings.apiKey
480
480
  (OpenCode cannot read the OS keychain). Keeps local-llm, plugins, and mcp.
481
481
  """
482
482
  from virtuai_cli.opencode import install_opencode
@@ -1,10 +1,11 @@
1
1
  """Point the local OpenCode TUI at VirtuAI agents.
2
2
 
3
- OpenCode is a Chat Completions client. ``virtuai opencode`` merges a
4
- ``provider.virtuai`` block into ``~/.config/opencode/opencode.json``. The
5
- ``apiKey`` is the same CLI pairing (or login) token already in the OS keychain
6
- — OpenCode cannot read the keychain, so the token is copied into that file as
7
- ``Authorization: Bearer``.
3
+ OpenCode is a Chat Completions client. ``virtuai opencode`` writes native
4
+ OpenCode 2 config into ``~/.config/opencode/opencode.json``: ``providers.virtuai``
5
+ and ``agents.*``. A previous ``provider.virtuai`` block this command wrote is
6
+ removed on the next run. The ``apiKey`` is the same CLI pairing (or login)
7
+ token already in the OS keychain — OpenCode cannot read the keychain, so the
8
+ token is copied into that file as ``Authorization: Bearer``.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -183,27 +184,85 @@ def _opencode_model_entry(m: dict) -> dict:
183
184
  entry["limit"] = {
184
185
  "context": context,
185
186
  "output": output,
186
- # OpenCode's usable() only applies compaction.reserved on the
187
+ # OpenCode's usable() applies the compaction buffer on the
187
188
  # limit.input branch; without this key the buffer is dead config.
188
189
  "input": int(limit.get("input") or max(0, context - output)),
189
190
  }
190
191
  return entry
191
192
 
192
193
 
193
- def _ensure_host_permissions(block: dict, *, read_only: bool) -> None:
194
- perm = block.setdefault("permission", {})
195
- if not isinstance(perm, dict):
196
- return
197
- perm.setdefault("read", "allow")
198
- perm.setdefault("grep", "allow")
199
- perm.setdefault("glob", "allow")
200
- if not read_only:
201
- perm.setdefault("bash", "allow")
202
- perm.setdefault("edit", "allow")
203
- # OpenCode asks for this after three byte-identical consecutive tool
204
- # calls. There is no approval path on this transport, so a prompt here
205
- # stalls the session.
206
- perm.setdefault("doom_loop", "allow")
194
+ # v1 permission names that OpenCode 2 renamed. Anything else is copied as-is.
195
+ _V1_PERMISSION_ACTIONS = {
196
+ "bash": "shell",
197
+ "write": "edit",
198
+ "patch": "edit",
199
+ "task": "subagent",
200
+ }
201
+ _BUILD_ALLOWS = ("read", "grep", "glob", "edit", "shell", "doom_loop")
202
+ _PLAN_ALLOWS = ("read", "grep", "glob")
203
+ # Values this command used to write. Only these are removed; a user edit stays.
204
+ _LEGACY_PRUNE = True
205
+ _LEGACY_RESERVED = 20_000
206
+
207
+
208
+ def _v1_permissions_to_v2(perm: dict) -> list[dict]:
209
+ rules: list[dict] = []
210
+ seen: set[str] = set()
211
+ for action, effect in perm.items():
212
+ if not isinstance(action, str) or not isinstance(effect, str):
213
+ continue
214
+ name = _V1_PERMISSION_ACTIONS.get(action, action)
215
+ if name in seen:
216
+ continue
217
+ seen.add(name)
218
+ rules.append({"action": name, "resource": "*", "effect": effect})
219
+ return rules
220
+
221
+
222
+ def _fill_allow_rules(block: dict, actions: tuple[str, ...]) -> None:
223
+ """Add allow-rules that are missing. Leave any rule the user already set."""
224
+ rules = block.get("permissions")
225
+ if not isinstance(rules, list):
226
+ rules = []
227
+ block["permissions"] = rules
228
+ present = {
229
+ rule.get("action")
230
+ for rule in rules
231
+ if isinstance(rule, dict) and isinstance(rule.get("action"), str)
232
+ }
233
+ for action in actions:
234
+ if action in present:
235
+ continue
236
+ rules.append({"action": action, "resource": "*", "effect": "allow"})
237
+ present.add(action)
238
+
239
+
240
+ def _lift_owned_agent(agents: dict, legacy: dict, name: str) -> dict:
241
+ """Move a VirtuAI-owned v1 agent under ``agents`` as one v2 object.
242
+
243
+ A native block already present wins. Otherwise the v1 block is copied and
244
+ its renamed fields are translated, then the v1 key is deleted so the agent
245
+ is not half ``permission`` and half ``permissions``.
246
+ """
247
+ current = agents.get(name)
248
+ if isinstance(current, dict):
249
+ block = current
250
+ else:
251
+ source = legacy.get(name)
252
+ block = deepcopy(source) if isinstance(source, dict) else {}
253
+ prompt = block.pop("prompt", None)
254
+ if prompt is not None and "system" not in block:
255
+ block["system"] = prompt
256
+ perm = block.pop("permission", None)
257
+ if isinstance(perm, dict) and not isinstance(block.get("permissions"), list):
258
+ block["permissions"] = _v1_permissions_to_v2(perm)
259
+ if "disable" in block and "disabled" not in block:
260
+ block["disabled"] = block.pop("disable")
261
+ else:
262
+ block.pop("disable", None)
263
+ agents[name] = block
264
+ legacy.pop(name, None)
265
+ return block
207
266
 
208
267
 
209
268
  def _ensure_compaction(cfg_out: dict) -> None:
@@ -215,19 +274,24 @@ def _ensure_compaction(cfg_out: dict) -> None:
215
274
  against token counts the agent loop reports. ``SummarizationMiddleware`` in
216
275
  the deep agent is what actually compacts.
217
276
 
218
- ``auto: false`` disables the automatic path on both the v1 and v2 runtimes.
219
- ``/compact`` still works as a manual escape hatch. ``prune`` is kept: it
220
- only clears stale tool output from OpenCode's own transcript, which is
221
- harmless and keeps its context gauge honest.
277
+ ``auto: false`` disables the automatic path. ``/compact`` still works as a
278
+ manual escape hatch. OpenCode 2 reads ``buffer`` and warns on ``prune``, so
279
+ this command no longer writes ``prune`` or ``reserved``.
222
280
 
223
- Existing user values are left alone.
281
+ Existing user values are left alone. ``buffer`` is filled from ``reserved``
282
+ when the user set a reserve and never set a buffer.
224
283
  """
225
284
  block = cfg_out.setdefault("compaction", {})
226
285
  if not isinstance(block, dict):
227
286
  return
228
287
  block.setdefault("auto", False)
229
- block.setdefault("prune", True)
230
- block.setdefault("reserved", 20_000)
288
+ if "buffer" not in block and "reserved" in block:
289
+ block["buffer"] = block["reserved"]
290
+ block.setdefault("buffer", _LEGACY_RESERVED)
291
+ if block.get("prune") is _LEGACY_PRUNE:
292
+ block.pop("prune", None)
293
+ if block.get("reserved") == _LEGACY_RESERVED:
294
+ block.pop("reserved", None)
231
295
 
232
296
 
233
297
  def merge_opencode_config(
@@ -238,18 +302,21 @@ def merge_opencode_config(
238
302
  models: list[dict],
239
303
  default_id: str,
240
304
  ) -> dict:
241
- """Merge a VirtuAI Completions provider. Preserves local-llm, plugins, mcp."""
305
+ """Merge a native OpenCode 2 VirtuAI provider. Preserves local-llm, plugins, mcp."""
242
306
  cfg_out = deepcopy(existing) if existing else {}
243
307
  cfg_out.setdefault("$schema", "https://opencode.ai/config.json")
244
308
  v1 = base_url.rstrip("/")
245
309
  if not v1.endswith("/v1"):
246
310
  v1 = f"{v1}/v1"
247
311
 
248
- provider = cfg_out.setdefault("provider", {})
249
- provider["virtuai"] = {
250
- "npm": "@ai-sdk/openai-compatible",
312
+ providers = cfg_out.setdefault("providers", {})
313
+ if not isinstance(providers, dict):
314
+ providers = {}
315
+ cfg_out["providers"] = providers
316
+ providers["virtuai"] = {
317
+ "package": "aisdk:@ai-sdk/openai-compatible",
251
318
  "name": "VirtuAI",
252
- "options": {
319
+ "settings": {
253
320
  "baseURL": v1,
254
321
  "apiKey": api_key,
255
322
  "includeUsage": True,
@@ -258,13 +325,16 @@ def merge_opencode_config(
258
325
  m["id"]: _opencode_model_entry(m) for m in models if m.get("id")
259
326
  },
260
327
  }
328
+ legacy_provider = cfg_out.get("provider")
329
+ if isinstance(legacy_provider, dict):
330
+ legacy_provider.pop("virtuai", None)
331
+ if not legacy_provider:
332
+ cfg_out.pop("provider", None)
261
333
 
262
334
  cfg_out["model"] = f"virtuai/{default_id}"
263
335
  # Title generation falls back to the session model when no small model is
264
336
  # set, which spends a full agent turn naming a session.
265
337
  small_id = pick_small_id(models, default_id)
266
- if small_id:
267
- cfg_out.setdefault("small_model", f"virtuai/{small_id}")
268
338
 
269
339
  by_slug: dict[str, str] = {}
270
340
  for m in models:
@@ -274,30 +344,68 @@ def merge_opencode_config(
274
344
  by_slug[_slug(mid)] = mid
275
345
  by_slug[_slug(m.get("name") or "")] = mid
276
346
 
347
+ agents = cfg_out.setdefault("agents", {})
348
+ if not isinstance(agents, dict):
349
+ agents = {}
350
+ cfg_out["agents"] = agents
351
+ legacy_was_dict = isinstance(cfg_out.get("agent"), dict)
352
+ legacy = cfg_out["agent"] if legacy_was_dict else {}
353
+
277
354
  build_id = pick_build_id(models)
278
355
  plan_id = pick_plan_id(models)
279
- agent = cfg_out.setdefault("agent", {})
280
356
  if build_id:
281
- build = agent.setdefault("build", {})
357
+ build = _lift_owned_agent(agents, legacy, "build")
282
358
  build["model"] = f"virtuai/{build_id}"
283
- _ensure_host_permissions(build, read_only=False)
359
+ _fill_allow_rules(build, _BUILD_ALLOWS)
284
360
  if plan_id:
285
- plan = agent.setdefault("plan", {})
361
+ plan = _lift_owned_agent(agents, legacy, "plan")
286
362
  plan["model"] = f"virtuai/{plan_id}"
287
- _ensure_host_permissions(plan, read_only=True)
288
- for key, block in list(agent.items()):
289
- if not isinstance(block, dict):
290
- continue
291
- if key in ("build", "plan"):
363
+ _fill_allow_rules(plan, _PLAN_ALLOWS)
364
+
365
+ owned = {"build", "plan", "compaction", "title"}
366
+ for key in list(legacy) + list(agents):
367
+ if key in owned:
292
368
  continue
293
369
  matched = by_slug.get(_slug(key))
294
- if matched:
295
- block["model"] = f"virtuai/{matched}"
296
-
297
- if small_id:
298
- # Manual /compact otherwise runs a full Build-grade agent turn; OpenCode
299
- # honours agent.compaction.model for it.
300
- agent.setdefault("compaction", {}).setdefault("model", f"virtuai/{small_id}")
370
+ if not matched:
371
+ continue
372
+ if isinstance(agents.get(key), dict):
373
+ agents[key]["model"] = f"virtuai/{matched}"
374
+ elif isinstance(legacy.get(key), dict):
375
+ legacy[key]["model"] = f"virtuai/{matched}"
376
+
377
+ prior_small = cfg_out.get("small_model")
378
+ title = agents.get("title")
379
+ if isinstance(title, dict) and title.get("model"):
380
+ cfg_out.pop("small_model", None)
381
+ else:
382
+ chosen = prior_small if isinstance(prior_small, str) and prior_small else None
383
+ if chosen is None and small_id:
384
+ chosen = f"virtuai/{small_id}"
385
+ if chosen:
386
+ if not isinstance(title, dict):
387
+ title = {}
388
+ agents["title"] = title
389
+ title.setdefault("model", chosen)
390
+ cfg_out.pop("small_model", None)
391
+
392
+ compaction_agent = agents.get("compaction")
393
+ if not isinstance(compaction_agent, dict):
394
+ compaction_agent = {}
395
+ legacy_compaction = legacy.pop("compaction", None)
396
+ if "model" not in compaction_agent:
397
+ if isinstance(legacy_compaction, dict) and legacy_compaction.get("model"):
398
+ compaction_agent["model"] = legacy_compaction["model"]
399
+ elif small_id:
400
+ compaction_agent["model"] = f"virtuai/{small_id}"
401
+ if compaction_agent.get("model"):
402
+ agents["compaction"] = compaction_agent
403
+
404
+ if legacy_was_dict:
405
+ if legacy:
406
+ cfg_out["agent"] = legacy
407
+ else:
408
+ cfg_out.pop("agent", None)
301
409
 
302
410
  _ensure_compaction(cfg_out)
303
411
  return cfg_out
@@ -95,11 +95,22 @@ _PASS_THROUGH_ENV = {
95
95
  "GOOGLE_CLOUD_PROJECT",
96
96
  "GCLOUD_PROJECT",
97
97
  "GOOGLE_CLOUD_QUOTA_PROJECT",
98
+ # Proxy settings. In a VirtuAI sandbox the only way out is its egress
99
+ # proxy, and without these every pip, curl and requests call the agent
100
+ # makes fails; on a laptop behind a corporate proxy, the same. They are
101
+ # set by whoever runs the CLI, never by the server.
102
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
103
+ "http_proxy", "https_proxy", "no_proxy", "all_proxy",
98
104
  }
99
105
 
100
106
  # Env var prefixes whose entire namespace is passed through
101
107
  _PASS_THROUGH_PREFIXES = ("VIRTUAI_", "GOOGLE_", "GCLOUD_", "CLOUDSDK_", "AWS_", "AZURE_")
102
108
 
109
+ # The runner's own credentials. Under the VIRTUAI_ prefix, they reached every
110
+ # command the agent ran — `env` printed the token, and with it anything the
111
+ # agent read could act as this runner.
112
+ _NEVER_PASSED = {"VIRTUAI_TOKEN", "VIRTUAI_PAIRING_CODE"}
113
+
103
114
 
104
115
  def scrub_env(workdir: Path, extra_allow: list[str] | None = None) -> dict[str, str]:
105
116
  """Build a clean environment: allowlist + cloud-tool prefixes + user opt-ins.
@@ -116,6 +127,8 @@ def scrub_env(workdir: Path, extra_allow: list[str] | None = None) -> dict[str,
116
127
 
117
128
  env: dict[str, str] = {}
118
129
  for key, value in os.environ.items():
130
+ if key in _NEVER_PASSED:
131
+ continue
119
132
  if key in allowed or any(key.startswith(p) for p in _PASS_THROUGH_PREFIXES):
120
133
  env[key] = value
121
134
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 1.0.4
3
+ Version: 1.0.6
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -31,6 +31,7 @@ tests/test_ollama_options.py
31
31
  tests/test_ollama_state.py
32
32
  tests/test_omlx.py
33
33
  tests/test_process_group_kill.py
34
+ tests/test_proxy_env.py
34
35
  tests/test_pull_fallback.py
35
36
  tests/test_run_single_instance.py
36
37
  tests/test_session_isolation.py
@@ -0,0 +1,29 @@
1
+ """The agent's commands keep the proxy settings the runner was started with.
2
+
3
+ In a VirtuAI sandbox the egress proxy is the only way out; without these,
4
+ every pip, curl and requests call failed.
5
+ """
6
+ import os
7
+ from pathlib import Path
8
+
9
+ from virtuai_cli.security import scrub_env
10
+
11
+
12
+ def test_proxy_settings_reach_the_agents_commands(monkeypatch, tmp_path: Path):
13
+ monkeypatch.setenv("HTTPS_PROXY", "http://px-7:3128")
14
+ monkeypatch.setenv("no_proxy", "localhost")
15
+ monkeypatch.setenv("SOME_SECRET", "x")
16
+ env = scrub_env(tmp_path)
17
+ assert env["HTTPS_PROXY"] == "http://px-7:3128" and env["no_proxy"] == "localhost"
18
+ assert "SOME_SECRET" not in env
19
+
20
+
21
+ def test_the_runners_own_credentials_never_reach_a_command(monkeypatch, tmp_path):
22
+ from virtuai_cli.security import scrub_env
23
+
24
+ monkeypatch.setenv("VIRTUAI_TOKEN", "vai_sbx_secret")
25
+ monkeypatch.setenv("VIRTUAI_PAIRING_CODE", "ABC-123")
26
+ monkeypatch.setenv("VIRTUAI_SERVER_URL", "https://dev.imvirtuai.com")
27
+ env = scrub_env(tmp_path)
28
+ assert "VIRTUAI_TOKEN" not in env and "VIRTUAI_PAIRING_CODE" not in env
29
+ assert env["VIRTUAI_SERVER_URL"] == "https://dev.imvirtuai.com" # the rest of VIRTUAI_* still passes
File without changes
File without changes