witbitz-code 1.7.4__tar.gz → 1.9.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.
- {witbitz_code-1.7.4/src/witbitz_code.egg-info → witbitz_code-1.9.0}/PKG-INFO +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/pyproject.toml +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/__init__.py +1 -1
- witbitz_code-1.9.0/src/witbitz_code/browse.py +187 -0
- witbitz_code-1.9.0/src/witbitz_code/browsetree.py +117 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/cli.py +18 -3
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/codetools.py +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/connector.py +27 -4
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/devicelink.py +4 -0
- witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +248 -0
- witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +223 -0
- witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +1399 -0
- witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +303 -0
- witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browser.js +168 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/relay.py +10 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/selfupdate.py +5 -4
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui.py +10 -6
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.css +1 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.js +27 -4
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/index.html +9 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/SOURCES.txt +9 -0
- witbitz_code-1.9.0/tests/test_browse.py +43 -0
- witbitz_code-1.9.0/tests/test_browsetree.py +131 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_devicelink.py +3 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_selfupdate.py +14 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_ui.py +28 -1
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/LICENSE +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/README.md +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/setup.cfg +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_images.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_wslstart.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.9.0"
|
|
8
8
|
description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The agent's browser on this computer — the twin of tools/browse-local-serve.mjs (2026-10-19; the owner: "I want to add the
|
|
2
|
+
browsing with agent capability in local code too" → "Both stages. Default.").
|
|
3
|
+
|
|
4
|
+
ON by default; `serve --no-browser` or WB_BROWSE_DISABLE=1 turns it off (and removes the agent's browser tools). The browser
|
|
5
|
+
programs are JavaScript (the cloud computer's own: browse-local, the agent's CLI, the publisher), so they run with this
|
|
6
|
+
computer's `node` — as the media tools do; without node the agent works without a browser. Each pairing gets a viewer key
|
|
7
|
+
derived from its secret (relay.browse_key = codeRelay.js deriveBrowseKey), so the app can Watch and Take over.
|
|
8
|
+
|
|
9
|
+
On Linux with the CLOUD COMPUTER'S browser installed (browsetree.py — the page's card, or `witbitz-browse-tree.mjs install`)
|
|
10
|
+
it is that one instead: the image's own Chromium, KasmVNC, PulseAudio and publisher under bwrap, sealed to the FIRST
|
|
11
|
+
pairing's key (the lab build seals to one). WB_BROWSE_TREE=0 keeps the laptop browser.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from . import browsetree, plugins
|
|
29
|
+
from .relay import RELAY_URL, browse_key
|
|
30
|
+
|
|
31
|
+
BROWSE_DIR = "witbitz-browse"
|
|
32
|
+
PLUGIN_REL = "plugins/witbitz-browser.js"
|
|
33
|
+
FILES = { # installed path (under OpenCode's config folder) → the packaged file (tools/browse-embed.mjs writes them)
|
|
34
|
+
f"{BROWSE_DIR}/browse-local.mjs": "witbitz-browse-local.mjs",
|
|
35
|
+
f"{BROWSE_DIR}/browse-cdp.mjs": "witbitz-browse-cdp.mjs",
|
|
36
|
+
f"{BROWSE_DIR}/browse-publisher.mjs": "witbitz-browse-publisher.mjs",
|
|
37
|
+
PLUGIN_REL: "witbitz-browser.js",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def disabled(args: list[str], env: dict | None = None) -> bool:
|
|
42
|
+
env = os.environ if env is None else env
|
|
43
|
+
return "--no-browser" in args or bool(re.fullmatch(r"(?i)1|true|yes", str(env.get("WB_BROWSE_DISABLE", ""))))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def install(config_dir: Path | None = None) -> dict[str, Path]:
|
|
47
|
+
"""The browser files into OpenCode's config folder (an identical file is left alone); → their paths by file name."""
|
|
48
|
+
root = Path(config_dir) if config_dir else plugins.opencode_config_dir()
|
|
49
|
+
out = {}
|
|
50
|
+
for rel, name in FILES.items():
|
|
51
|
+
target = root / rel
|
|
52
|
+
text = plugins.packaged(name)
|
|
53
|
+
try:
|
|
54
|
+
same = target.read_text(encoding="utf-8") == text
|
|
55
|
+
except (OSError, UnicodeDecodeError):
|
|
56
|
+
same = False
|
|
57
|
+
if not same:
|
|
58
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
target.write_text(text, encoding="utf-8", newline="")
|
|
60
|
+
out[Path(rel).name] = target
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def remove(config_dir: Path | None = None) -> None:
|
|
65
|
+
"""Turned off: the agent's browser tools go, so OpenCode never offers a tool that can only fail."""
|
|
66
|
+
root = Path(config_dir) if config_dir else plugins.opencode_config_dir()
|
|
67
|
+
try:
|
|
68
|
+
(root / PLUGIN_REL).unlink()
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def node_major(node: str) -> int:
|
|
74
|
+
try:
|
|
75
|
+
v = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=10).stdout.strip()
|
|
76
|
+
return int(v.lstrip("v").split(".")[0])
|
|
77
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _free_port() -> int:
|
|
82
|
+
with socket.socket() as s:
|
|
83
|
+
s.bind(("127.0.0.1", 0))
|
|
84
|
+
return s.getsockname()[1]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class LocalBrowser:
|
|
88
|
+
def __init__(self, proc: asyncio.subprocess.Process, ctl: int, env: dict, http: httpx.AsyncClient, tree: str = "") -> None:
|
|
89
|
+
self.proc, self.ctl, self.env, self._http, self.tree = proc, ctl, env, http, tree
|
|
90
|
+
self.last: dict | None = None
|
|
91
|
+
self._task: asyncio.Task | None = None
|
|
92
|
+
|
|
93
|
+
def probe(self) -> dict | None:
|
|
94
|
+
"""The browser's last /status — synchronous: the connector's hello must never wait on it. The cloud computer's
|
|
95
|
+
browser answers as in the enclave; `local`/`cast` tell the app to watch it as this computer's, `tree` which image."""
|
|
96
|
+
if self.last is not None and self.tree:
|
|
97
|
+
return {**self.last, "local": True, "cast": True, "tree": self.tree}
|
|
98
|
+
return self.last
|
|
99
|
+
|
|
100
|
+
async def _poll(self) -> None:
|
|
101
|
+
try:
|
|
102
|
+
r = await self._http.get(f"http://127.0.0.1:{self.ctl}/status", timeout=1.5)
|
|
103
|
+
self.last = r.json() if r.status_code == 200 else None
|
|
104
|
+
except (httpx.HTTPError, ValueError):
|
|
105
|
+
self.last = None
|
|
106
|
+
|
|
107
|
+
async def _every(self) -> None:
|
|
108
|
+
while True:
|
|
109
|
+
await asyncio.sleep(2)
|
|
110
|
+
await self._poll()
|
|
111
|
+
|
|
112
|
+
async def stop(self) -> None:
|
|
113
|
+
if self._task:
|
|
114
|
+
self._task.cancel()
|
|
115
|
+
if self.proc.returncode is None:
|
|
116
|
+
try:
|
|
117
|
+
self.proc.terminate()
|
|
118
|
+
await asyncio.wait_for(self.proc.wait(), 5)
|
|
119
|
+
except (ProcessLookupError, asyncio.TimeoutError):
|
|
120
|
+
try:
|
|
121
|
+
self.proc.kill()
|
|
122
|
+
except ProcessLookupError:
|
|
123
|
+
pass
|
|
124
|
+
await self._http.aclose()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Callable[[str], Any] = print,
|
|
128
|
+
node: str | None = None) -> LocalBrowser | None:
|
|
129
|
+
"""Start it; → a LocalBrowser (env for OpenCode, probe for the hello), or None when this computer has no node."""
|
|
130
|
+
node = node or shutil.which("node")
|
|
131
|
+
if not node:
|
|
132
|
+
log("witbitz-code: no node on this computer — the agent works without a browser (install Node.js 22+ to add one)")
|
|
133
|
+
return None
|
|
134
|
+
paths = await asyncio.to_thread(install, config_dir)
|
|
135
|
+
if browsetree.use_tree():
|
|
136
|
+
b = await _start_tree(node, paths, pairings, config_dir, log)
|
|
137
|
+
if b:
|
|
138
|
+
return b
|
|
139
|
+
watchable = node_major(node) >= 22 # the publisher needs a global WebSocket
|
|
140
|
+
keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
|
|
141
|
+
ctl, gate = _free_port(), _free_port()
|
|
142
|
+
env = {**os.environ, "WB_BROWSE_CTL_PORT": str(ctl), "WB_BROWSE_GATE_PORT": str(gate), "WB_BROWSE_KEYS": ",".join(keys),
|
|
143
|
+
"WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid())}
|
|
144
|
+
proc = await asyncio.create_subprocess_exec(node, str(paths["browse-local.mjs"]), env=env, stdin=asyncio.subprocess.DEVNULL,
|
|
145
|
+
stdout=asyncio.subprocess.DEVNULL)
|
|
146
|
+
b = LocalBrowser(proc, ctl, {"WB_BROWSE_NODE": node, "WB_BROWSE_CDP_CLI": str(paths["browse-cdp.mjs"]),
|
|
147
|
+
"WB_BROWSE_CTL": f"http://127.0.0.1:{ctl}", "WB_BROWSE_CDP": f"http://127.0.0.1:{gate}"},
|
|
148
|
+
httpx.AsyncClient())
|
|
149
|
+
for _ in range(20): # its control port answers within a moment
|
|
150
|
+
await b._poll()
|
|
151
|
+
if b.last is not None:
|
|
152
|
+
break
|
|
153
|
+
await asyncio.sleep(0.25)
|
|
154
|
+
b._task = asyncio.ensure_future(b._every())
|
|
155
|
+
log("witbitz-code: the agent's browser is ready on this computer"
|
|
156
|
+
+ (" — watch it from the Code section" if watchable else " (node is older than 22: the app cannot watch it)") + "; off: serve --no-browser")
|
|
157
|
+
return b
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _start_tree(node: str, paths: dict, pairings: list[dict], config_dir: Path | None, log: Callable[[str], Any]) -> LocalBrowser | None:
|
|
161
|
+
"""The cloud computer's browser when it is installed here; None → the laptop browser."""
|
|
162
|
+
key = next((browse_key(p["secret"]) for p in pairings if p.get("secret")), "")
|
|
163
|
+
tree = await asyncio.to_thread(browsetree.script, config_dir)
|
|
164
|
+
st = await asyncio.to_thread(browsetree.read_status, node, tree)
|
|
165
|
+
if not key or not st.get("supported") or not st.get("installed"):
|
|
166
|
+
return None
|
|
167
|
+
proc = await asyncio.create_subprocess_exec(node, str(tree), "run", "--relay", RELAY_URL, "--parent", str(os.getpid()),
|
|
168
|
+
env={**os.environ, "WB_BROWSE_KEY": key}, stdin=asyncio.subprocess.DEVNULL,
|
|
169
|
+
stdout=asyncio.subprocess.PIPE)
|
|
170
|
+
try:
|
|
171
|
+
assert proc.stdout
|
|
172
|
+
ready = json.loads(await asyncio.wait_for(proc.stdout.readline(), 40))
|
|
173
|
+
except (ValueError, asyncio.TimeoutError, AssertionError):
|
|
174
|
+
ready = {}
|
|
175
|
+
if not ready.get("ready"):
|
|
176
|
+
log(f"witbitz-code: the cloud computer's browser did not start ({ready.get('error') or 'no answer'}) — using the laptop browser")
|
|
177
|
+
if proc.returncode is None:
|
|
178
|
+
proc.kill()
|
|
179
|
+
return None
|
|
180
|
+
env = {k: str(v) for k, v in ready.items() if k.startswith("WB_BROWSE_")}
|
|
181
|
+
env["WB_BROWSE_CDP_CLI"] = str(paths["browse-cdp.mjs"])
|
|
182
|
+
b = LocalBrowser(proc, int(env["WB_BROWSE_CTL"].rsplit(":", 1)[1]), env, httpx.AsyncClient(), tree=str(ready.get("tag") or ""))
|
|
183
|
+
await b._poll()
|
|
184
|
+
b._task = asyncio.ensure_future(b._every())
|
|
185
|
+
if st.get("current") is False:
|
|
186
|
+
log(f"witbitz-code: this release pins the cloud browser {st['pinned']['tag']} — update it on the witbitz-code page")
|
|
187
|
+
return b
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""THE CLOUD COMPUTER'S BROWSER ON THIS LINUX COMPUTER — the page's card and serve's choice (the owner, 2026-10-19: "why isnt
|
|
2
|
+
it part of the pipx install? and why isnt it installed from the witbitz code webpage?").
|
|
3
|
+
|
|
4
|
+
The program is `witbitz-browse-tree.mjs` (tools/browse-tree/cli.mjs, bundled with this release's pin by tools/browse-embed.mjs)
|
|
5
|
+
run with this computer's node: `status` says what is pinned and installed, `install --json` downloads the pinned image's tree
|
|
6
|
+
(~650 MB, both sha256s checked before anything is unpacked) with progress lines, `run` starts it under bwrap. Nothing is
|
|
7
|
+
downloaded until the person presses Install; a release that pins a newer image shows it as an update.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable
|
|
21
|
+
|
|
22
|
+
from . import plugins
|
|
23
|
+
|
|
24
|
+
SCRIPT_REL = "witbitz-browse/browse-tree.mjs"
|
|
25
|
+
PACKAGED = "witbitz-browse-tree.mjs"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def script(config_dir: Path | None = None) -> Path:
|
|
29
|
+
"""The program in OpenCode's config folder, beside the other browser files (an identical file is left alone)."""
|
|
30
|
+
target = (Path(config_dir) if config_dir else plugins.opencode_config_dir()) / SCRIPT_REL
|
|
31
|
+
text = plugins.packaged(PACKAGED)
|
|
32
|
+
try:
|
|
33
|
+
same = target.read_text(encoding="utf-8") == text
|
|
34
|
+
except (OSError, UnicodeDecodeError):
|
|
35
|
+
same = False
|
|
36
|
+
if not same:
|
|
37
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
target.write_text(text, encoding="utf-8", newline="")
|
|
39
|
+
return target
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_status(node: str, path: Path) -> dict:
|
|
43
|
+
"""`status` → {linux, needs, supported, pinned, installed, current, why}; {error} when it could not be asked."""
|
|
44
|
+
try:
|
|
45
|
+
r = subprocess.run([node, str(path), "status"], capture_output=True, text=True, timeout=20, check=False) # noqa: S603
|
|
46
|
+
return json.loads(r.stdout.strip().splitlines()[-1])
|
|
47
|
+
except (OSError, ValueError, IndexError, subprocess.SubprocessError) as e:
|
|
48
|
+
return {"error": f"could not check ({str(e)[:80]})"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Tree:
|
|
52
|
+
"""The page's card: {shown, state: idle|installing|done|failed, step, pct, error, ...status}. Outside reaches are injected."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, *, node: Callable[[], str | None] = lambda: shutil.which("node"),
|
|
55
|
+
script_path: Callable[[], Path] = script, read: Callable[[str, Path], dict] = read_status,
|
|
56
|
+
platform: str = sys.platform) -> None:
|
|
57
|
+
self._node, self._script, self._read, self._platform = node, script_path, read, platform
|
|
58
|
+
self.state, self.step, self.pct, self.error, self.tag = "idle", "", 0.0, "", ""
|
|
59
|
+
self.task: asyncio.Task | None = None
|
|
60
|
+
self._cache: tuple[float, dict] | None = None
|
|
61
|
+
|
|
62
|
+
def status(self) -> dict:
|
|
63
|
+
if not self._platform.startswith("linux"):
|
|
64
|
+
return {"shown": False}
|
|
65
|
+
live = {"state": self.state, "step": self.step, "pct": self.pct, "error": self.error, "tag": self.tag}
|
|
66
|
+
if self._cache and time.monotonic() - self._cache[0] < 15 and self.state != "installing":
|
|
67
|
+
return {**self._cache[1], **live}
|
|
68
|
+
node = self._node()
|
|
69
|
+
if not node:
|
|
70
|
+
st = {"shown": True, "supported": False, "needs": ["Node.js 22.15+"]}
|
|
71
|
+
else:
|
|
72
|
+
try:
|
|
73
|
+
st = {"shown": True, **self._read(node, self._script())}
|
|
74
|
+
except OSError as e:
|
|
75
|
+
st = {"shown": True, "error": f"could not check ({str(e)[:80]})"}
|
|
76
|
+
self._cache = (time.monotonic(), st)
|
|
77
|
+
return {**st, **live}
|
|
78
|
+
|
|
79
|
+
async def install(self) -> dict:
|
|
80
|
+
if self.task and not self.task.done():
|
|
81
|
+
return {"ok": True}
|
|
82
|
+
node = self._node()
|
|
83
|
+
if not node or not self._platform.startswith("linux"):
|
|
84
|
+
return {"ok": False, "why": "the cloud computer's browser needs Linux and Node.js"}
|
|
85
|
+
self.state, self.step, self.pct, self.error = "installing", "download", 0.0, ""
|
|
86
|
+
self.task = asyncio.ensure_future(self._install(node))
|
|
87
|
+
return {"ok": True}
|
|
88
|
+
|
|
89
|
+
async def _install(self, node: str) -> None:
|
|
90
|
+
try:
|
|
91
|
+
proc = await asyncio.create_subprocess_exec(node, str(self._script()), "install", "--json", stdin=asyncio.subprocess.DEVNULL,
|
|
92
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
|
|
93
|
+
assert proc.stdout
|
|
94
|
+
async for raw in proc.stdout:
|
|
95
|
+
try:
|
|
96
|
+
m: dict[str, Any] = json.loads(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
continue
|
|
99
|
+
if m.get("error"):
|
|
100
|
+
self.error = str(m["error"])[:300]
|
|
101
|
+
elif m.get("ok"):
|
|
102
|
+
self.tag = str(m.get("tag") or "")
|
|
103
|
+
else:
|
|
104
|
+
self.step, self.pct = str(m.get("step") or ""), float(m.get("pct") or 0)
|
|
105
|
+
await proc.wait()
|
|
106
|
+
if proc.returncode and not self.error:
|
|
107
|
+
self.error = f"the install stopped ({proc.returncode})"
|
|
108
|
+
except (OSError, asyncio.CancelledError) as e:
|
|
109
|
+
self.error = self.error or f"could not run it ({str(e)[:120]})"
|
|
110
|
+
finally:
|
|
111
|
+
self._cache = None
|
|
112
|
+
self.state = "failed" if self.error else "done"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def use_tree(env: dict | None = None, platform: str = sys.platform) -> bool:
|
|
116
|
+
env = os.environ if env is None else env
|
|
117
|
+
return platform.startswith("linux") and str(env.get("WB_BROWSE_TREE", "")) != "0"
|
|
@@ -26,7 +26,7 @@ import httpx
|
|
|
26
26
|
from . import __version__
|
|
27
27
|
from . import pair as pair_cmd
|
|
28
28
|
from . import pairings as pp
|
|
29
|
-
from . import codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winbg, winconsole, wizard, wslstart
|
|
29
|
+
from . import browse as browse_mod, codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winbg, winconsole, wizard, wslstart
|
|
30
30
|
from ._js import to_number, truthy
|
|
31
31
|
from .connector import pairings_for_port, start_connector
|
|
32
32
|
from .payers import SessionPayers
|
|
@@ -41,7 +41,7 @@ HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from t
|
|
|
41
41
|
start when I log in (Windows and WSL); it serves while it runs. --background (Windows):
|
|
42
42
|
no window — the page's Quit stops it
|
|
43
43
|
pair [--name <name>] show a QR code; scan it in Spaces (Settings → Privacy & security → Add a device)
|
|
44
|
-
serve [--port <n>] [--no-opencode]
|
|
44
|
+
serve [--port <n>] [--no-opencode] [--no-browser]
|
|
45
45
|
start OpenCode on 127.0.0.1 with your account's keys, and the connector
|
|
46
46
|
status list this computer's pairings and their coding keys
|
|
47
47
|
rotate [--account <email>] replace the pairing secret(s) without a scan, then restart serve
|
|
@@ -217,6 +217,7 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
217
217
|
stop = stop or _Stop()
|
|
218
218
|
child = None
|
|
219
219
|
proxy = None
|
|
220
|
+
browser = None
|
|
220
221
|
live: dict = {"connector": None}
|
|
221
222
|
keys_http = httpx.AsyncClient(timeout=30)
|
|
222
223
|
try:
|
|
@@ -245,6 +246,17 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
245
246
|
_err(f"witbitz-code: OpenCode is already running on 127.0.0.1:{port}, started some other way — it cannot use your account's keys.")
|
|
246
247
|
_err(" Close it, then run witbitz-code serve again (it starts OpenCode itself).")
|
|
247
248
|
return 1
|
|
249
|
+
# THE AGENT'S BROWSER (browse.py): ON unless --no-browser / WB_BROWSE_DISABLE=1 — started BEFORE OpenCode, whose
|
|
250
|
+
# plugin reads where it is; watched from the Code section like a cloud computer's.
|
|
251
|
+
if browse_mod.disabled(args):
|
|
252
|
+
browse_mod.remove()
|
|
253
|
+
else:
|
|
254
|
+
try:
|
|
255
|
+
browser = await browse_mod.start(pairings, log=_err)
|
|
256
|
+
except OSError as e:
|
|
257
|
+
_err(f"witbitz-code: the agent's browser could not start ({e}) — the agent works without it")
|
|
258
|
+
if browser:
|
|
259
|
+
tools_env = {**tools_env, **browser.env}
|
|
248
260
|
if "--no-opencode" not in args:
|
|
249
261
|
exe = wizard.find_opencode()
|
|
250
262
|
if not exe:
|
|
@@ -262,7 +274,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
262
274
|
if child is not None:
|
|
263
275
|
_err("witbitz-code: model calls are paid by each account's own coding keys; confidential models are enforced (min_privacy + verified receipts)")
|
|
264
276
|
# Auto mode's reviewer asks the policy of the model proxy directly (auto_runner.REVIEW_MODELS), billed like the session.
|
|
265
|
-
connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else ""
|
|
277
|
+
connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else "",
|
|
278
|
+
browse_probe=browser.probe if browser else None)
|
|
266
279
|
live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
|
|
267
280
|
live["connector"] = connector
|
|
268
281
|
if live_out is not None:
|
|
@@ -281,6 +294,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
281
294
|
finally:
|
|
282
295
|
if child is not None:
|
|
283
296
|
await _stop_opencode(child)
|
|
297
|
+
if browser is not None:
|
|
298
|
+
await browser.stop()
|
|
284
299
|
if proxy is not None:
|
|
285
300
|
await proxy.close()
|
|
286
301
|
await keys_http.aclose()
|
|
@@ -39,7 +39,7 @@ Tools you may expect from other setups, and what stands in for them here — do
|
|
|
39
39
|
- todoread → todowrite returns the whole list on every write.
|
|
40
40
|
- multiedit / patch → edit, one file at a time, or apply_patch where your model is offered it.
|
|
41
41
|
- websearch → web_search (witbitz-media): confidential, answered by a search agent inside a hardware enclave; then webfetch a source for its full text.
|
|
42
|
-
- browser → browser_open / browser_navigate / browser_snapshot / browser_click / browser_type / browser_key / browser_scroll / browser_back / browser_screenshot, when this computer offers them; act from a snapshot's numbered refs, not from a screenshot.
|
|
42
|
+
- browser → browser_open / browser_navigate / browser_snapshot / browser_click / browser_type / browser_key / browser_scroll / browser_back / browser_screenshot / browser_close, when this computer offers them; close it when you are done; act from a snapshot's numbered refs, not from a screenshot.
|
|
43
43
|
- lsp is here when the config enables a language server for the file type (Python: pyright); otherwise run the project's own typecheck or lint.
|
|
44
44
|
"""
|
|
45
45
|
|
|
@@ -160,8 +160,12 @@ class PairingServer:
|
|
|
160
160
|
attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
|
|
161
161
|
asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
|
|
162
162
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
163
|
-
model_url: str = "", remind_times: dict | None = None
|
|
163
|
+
model_url: str = "", remind_times: dict | None = None,
|
|
164
|
+
browse_probe: Callable[[], dict | None] | None = None) -> None:
|
|
164
165
|
self.pairing = pairing
|
|
166
|
+
# THE AGENT'S BROWSER ON THIS COMPUTER (browse.py): its /status rides the hello; a change is announced within 2 s
|
|
167
|
+
self._browse_probe = browse_probe
|
|
168
|
+
self._browse_said = "" # the last browse report the page was told, as JSON
|
|
165
169
|
self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
|
|
166
170
|
# WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
|
|
167
171
|
self._payers = payers
|
|
@@ -227,6 +231,8 @@ class PairingServer:
|
|
|
227
231
|
self._spawn(self._every(SWEEP_EVERY_S, self._sweep))
|
|
228
232
|
self._spawn(self._every(self._remind_rt.get("every_s") or REMIND_EVERY_S, lambda: self._spawn(self._remind_tick())))
|
|
229
233
|
self._spawn(self._every(HELLO_EVERY_S, lambda: self.hello() if self.peer.peers >= 2 else None))
|
|
234
|
+
if self._browse_probe:
|
|
235
|
+
self._spawn(self._every(2, self._browse_changed))
|
|
230
236
|
self._spawn(self._watch_asks())
|
|
231
237
|
self._spawn(self._every(self._asks_reconcile_s, lambda: self._spawn(self._reconcile_asks()) if self._asks_live else None))
|
|
232
238
|
self.auto.start()
|
|
@@ -290,12 +296,27 @@ class PairingServer:
|
|
|
290
296
|
except Exception as e:
|
|
291
297
|
self._log(f"witbitz-code: {self.name} · {e}")
|
|
292
298
|
|
|
299
|
+
def _browse_report(self) -> dict | None:
|
|
300
|
+
try:
|
|
301
|
+
r = self._browse_probe() if self._browse_probe else None
|
|
302
|
+
except Exception: # noqa: BLE001 — a probe that throws reports nothing
|
|
303
|
+
r = None
|
|
304
|
+
return r if isinstance(r, dict) else None
|
|
305
|
+
|
|
306
|
+
def _browse_changed(self) -> None:
|
|
307
|
+
now = json.dumps(self._browse_report(), sort_keys=True)
|
|
308
|
+
if now != self._browse_said and self.peer.peers >= 2:
|
|
309
|
+
self.hello()
|
|
310
|
+
|
|
293
311
|
def hello(self) -> asyncio.Future | None:
|
|
294
312
|
if not self.nonce:
|
|
295
313
|
return None # no hello before this socket has its nonce
|
|
314
|
+
browse = self._browse_report()
|
|
315
|
+
self._browse_said = json.dumps(browse, sort_keys=True)
|
|
296
316
|
# caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
|
|
297
317
|
return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
|
|
298
|
-
"k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], "auto": self.auto.sessions()
|
|
318
|
+
"k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], "auto": self.auto.sessions(),
|
|
319
|
+
**({"browse": browse} if browse else {})})
|
|
299
320
|
|
|
300
321
|
def _auth(self) -> dict:
|
|
301
322
|
pw = self.pairing.get("password") or read_env_password(Path(self.pairing["envFile"]) if self.pairing.get("envFile") else None)
|
|
@@ -941,7 +962,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
941
962
|
attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
|
|
942
963
|
asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
|
|
943
964
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
944
|
-
model_url: str = "", remind_times: dict | None = None
|
|
965
|
+
model_url: str = "", remind_times: dict | None = None,
|
|
966
|
+
browse_probe: Callable[[], dict | None] | None = None) -> Connector:
|
|
945
967
|
"""Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
|
|
946
968
|
request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
|
|
947
969
|
client = client or local_client()
|
|
@@ -956,7 +978,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
956
978
|
s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
|
|
957
979
|
max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
|
|
958
980
|
attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
|
|
959
|
-
notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times
|
|
981
|
+
notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times,
|
|
982
|
+
browse_probe=browse_probe)
|
|
960
983
|
await s.start()
|
|
961
984
|
servers.append(s)
|
|
962
985
|
return Connector(servers, client, payers)
|
|
@@ -116,6 +116,9 @@ def read_link_payload(p: Any) -> dict | None:
|
|
|
116
116
|
anchor = {"method": a["method"], "email": a["email"]}
|
|
117
117
|
if isinstance(token, str) and token:
|
|
118
118
|
anchor["token"] = token
|
|
119
|
+
kp = a.get("kp")
|
|
120
|
+
if isinstance(kp, dict) and truthy(kp.get("priv")) and truthy(kp.get("pub")):
|
|
121
|
+
anchor["kp"] = {"priv": kp["priv"], "pub": kp["pub"]}
|
|
119
122
|
if truthy(a.get("exp")):
|
|
120
123
|
anchor["exp"] = a["exp"]
|
|
121
124
|
rel = p.get("rel")
|
|
@@ -128,6 +131,7 @@ def read_link_payload(p: Any) -> dict | None:
|
|
|
128
131
|
if isinstance(rel, dict) and isinstance(rel.get("token"), str) and rel["token"] else None,
|
|
129
132
|
"idx": _ptr(p.get("idx")),
|
|
130
133
|
"pipes": _ptr(p.get("pipes")),
|
|
134
|
+
"msgs": _ptr(p.get("msgs")), # the message room (deviceLink.js since b27a27d0)
|
|
131
135
|
"anchor": anchor,
|
|
132
136
|
}
|
|
133
137
|
|