witbitz-code 1.7.4__tar.gz → 1.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.
- {witbitz_code-1.7.4/src/witbitz_code.egg-info → witbitz_code-1.8.0}/PKG-INFO +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/pyproject.toml +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/__init__.py +1 -1
- witbitz_code-1.8.0/src/witbitz_code/browse.py +145 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/cli.py +18 -3
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/codetools.py +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/connector.py +27 -4
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/devicelink.py +4 -0
- witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +248 -0
- witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +223 -0
- witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +1395 -0
- witbitz_code-1.8.0/src/witbitz_code/opencode_plugins/witbitz-browser.js +168 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/relay.py +10 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/selfupdate.py +5 -4
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui.py +4 -3
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui_static/app.js +4 -4
- {witbitz_code-1.7.4 → witbitz_code-1.8.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/SOURCES.txt +6 -0
- witbitz_code-1.8.0/tests/test_browse.py +43 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_devicelink.py +3 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_selfupdate.py +14 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_ui.py +28 -1
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/LICENSE +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/README.md +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/setup.cfg +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_images.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.7.4 → witbitz_code-1.8.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.8.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,145 @@
|
|
|
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
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from . import plugins
|
|
24
|
+
from .relay import browse_key
|
|
25
|
+
|
|
26
|
+
BROWSE_DIR = "witbitz-browse"
|
|
27
|
+
PLUGIN_REL = "plugins/witbitz-browser.js"
|
|
28
|
+
FILES = { # installed path (under OpenCode's config folder) → the packaged file (tools/browse-embed.mjs writes them)
|
|
29
|
+
f"{BROWSE_DIR}/browse-local.mjs": "witbitz-browse-local.mjs",
|
|
30
|
+
f"{BROWSE_DIR}/browse-cdp.mjs": "witbitz-browse-cdp.mjs",
|
|
31
|
+
f"{BROWSE_DIR}/browse-publisher.mjs": "witbitz-browse-publisher.mjs",
|
|
32
|
+
PLUGIN_REL: "witbitz-browser.js",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def disabled(args: list[str], env: dict | None = None) -> bool:
|
|
37
|
+
env = os.environ if env is None else env
|
|
38
|
+
return "--no-browser" in args or bool(re.fullmatch(r"(?i)1|true|yes", str(env.get("WB_BROWSE_DISABLE", ""))))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def install(config_dir: Path | None = None) -> dict[str, Path]:
|
|
42
|
+
"""The browser files into OpenCode's config folder (an identical file is left alone); → their paths by file name."""
|
|
43
|
+
root = Path(config_dir) if config_dir else plugins.opencode_config_dir()
|
|
44
|
+
out = {}
|
|
45
|
+
for rel, name in FILES.items():
|
|
46
|
+
target = root / rel
|
|
47
|
+
text = plugins.packaged(name)
|
|
48
|
+
try:
|
|
49
|
+
same = target.read_text(encoding="utf-8") == text
|
|
50
|
+
except (OSError, UnicodeDecodeError):
|
|
51
|
+
same = False
|
|
52
|
+
if not same:
|
|
53
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
target.write_text(text, encoding="utf-8", newline="")
|
|
55
|
+
out[Path(rel).name] = target
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remove(config_dir: Path | None = None) -> None:
|
|
60
|
+
"""Turned off: the agent's browser tools go, so OpenCode never offers a tool that can only fail."""
|
|
61
|
+
root = Path(config_dir) if config_dir else plugins.opencode_config_dir()
|
|
62
|
+
try:
|
|
63
|
+
(root / PLUGIN_REL).unlink()
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def node_major(node: str) -> int:
|
|
69
|
+
try:
|
|
70
|
+
v = subprocess.run([node, "--version"], capture_output=True, text=True, timeout=10).stdout.strip()
|
|
71
|
+
return int(v.lstrip("v").split(".")[0])
|
|
72
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _free_port() -> int:
|
|
77
|
+
with socket.socket() as s:
|
|
78
|
+
s.bind(("127.0.0.1", 0))
|
|
79
|
+
return s.getsockname()[1]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LocalBrowser:
|
|
83
|
+
def __init__(self, proc: asyncio.subprocess.Process, ctl: int, env: dict, http: httpx.AsyncClient) -> None:
|
|
84
|
+
self.proc, self.ctl, self.env, self._http = proc, ctl, env, http
|
|
85
|
+
self.last: dict | None = None
|
|
86
|
+
self._task: asyncio.Task | None = None
|
|
87
|
+
|
|
88
|
+
def probe(self) -> dict | None:
|
|
89
|
+
"""The browser's last /status — synchronous: the connector's hello must never wait on it."""
|
|
90
|
+
return self.last
|
|
91
|
+
|
|
92
|
+
async def _poll(self) -> None:
|
|
93
|
+
try:
|
|
94
|
+
r = await self._http.get(f"http://127.0.0.1:{self.ctl}/status", timeout=1.5)
|
|
95
|
+
self.last = r.json() if r.status_code == 200 else None
|
|
96
|
+
except (httpx.HTTPError, ValueError):
|
|
97
|
+
self.last = None
|
|
98
|
+
|
|
99
|
+
async def _every(self) -> None:
|
|
100
|
+
while True:
|
|
101
|
+
await asyncio.sleep(2)
|
|
102
|
+
await self._poll()
|
|
103
|
+
|
|
104
|
+
async def stop(self) -> None:
|
|
105
|
+
if self._task:
|
|
106
|
+
self._task.cancel()
|
|
107
|
+
if self.proc.returncode is None:
|
|
108
|
+
try:
|
|
109
|
+
self.proc.terminate()
|
|
110
|
+
await asyncio.wait_for(self.proc.wait(), 5)
|
|
111
|
+
except (ProcessLookupError, asyncio.TimeoutError):
|
|
112
|
+
try:
|
|
113
|
+
self.proc.kill()
|
|
114
|
+
except ProcessLookupError:
|
|
115
|
+
pass
|
|
116
|
+
await self._http.aclose()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Callable[[str], Any] = print,
|
|
120
|
+
node: str | None = None) -> LocalBrowser | None:
|
|
121
|
+
"""Start it; → a LocalBrowser (env for OpenCode, probe for the hello), or None when this computer has no node."""
|
|
122
|
+
node = node or shutil.which("node")
|
|
123
|
+
if not node:
|
|
124
|
+
log("witbitz-code: no node on this computer — the agent works without a browser (install Node.js 22+ to add one)")
|
|
125
|
+
return None
|
|
126
|
+
paths = await asyncio.to_thread(install, config_dir)
|
|
127
|
+
watchable = node_major(node) >= 22 # the publisher needs a global WebSocket
|
|
128
|
+
keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
|
|
129
|
+
ctl, gate = _free_port(), _free_port()
|
|
130
|
+
env = {**os.environ, "WB_BROWSE_CTL_PORT": str(ctl), "WB_BROWSE_GATE_PORT": str(gate), "WB_BROWSE_KEYS": ",".join(keys),
|
|
131
|
+
"WB_BROWSE_PUBLISHER": str(paths["browse-publisher.mjs"]), "WB_BROWSE_PARENT": str(os.getpid())}
|
|
132
|
+
proc = await asyncio.create_subprocess_exec(node, str(paths["browse-local.mjs"]), env=env, stdin=asyncio.subprocess.DEVNULL,
|
|
133
|
+
stdout=asyncio.subprocess.DEVNULL)
|
|
134
|
+
b = LocalBrowser(proc, ctl, {"WB_BROWSE_NODE": node, "WB_BROWSE_CDP_CLI": str(paths["browse-cdp.mjs"]),
|
|
135
|
+
"WB_BROWSE_CTL": f"http://127.0.0.1:{ctl}", "WB_BROWSE_CDP": f"http://127.0.0.1:{gate}"},
|
|
136
|
+
httpx.AsyncClient())
|
|
137
|
+
for _ in range(20): # its control port answers within a moment
|
|
138
|
+
await b._poll()
|
|
139
|
+
if b.last is not None:
|
|
140
|
+
break
|
|
141
|
+
await asyncio.sleep(0.25)
|
|
142
|
+
b._task = asyncio.ensure_future(b._every())
|
|
143
|
+
log("witbitz-code: the agent's browser is ready on this computer"
|
|
144
|
+
+ (" — watch it from the Code section" if watchable else " (node is older than 22: the app cannot watch it)") + "; off: serve --no-browser")
|
|
145
|
+
return b
|
|
@@ -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
|
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// browse-cdp.mjs — what the AGENT does to its browser, over CDP (docs/cloud-computer.md § 12).
|
|
2
|
+
//
|
|
3
|
+
// node browse-cdp.mjs <op> ['{"json":"args"}'] → one JSON object on stdout
|
|
4
|
+
//
|
|
5
|
+
// It talks to the CDP GATE (browse.go), never to Chromium directly, so the enclave can close the agent's session
|
|
6
|
+
// the moment the owner takes the controls — and a tool that runs during a take-over fails plainly rather than
|
|
7
|
+
// quietly reading a screen someone is typing a password into.
|
|
8
|
+
//
|
|
9
|
+
// ★ ACCESSIBILITY, NOT PIXELS. `snapshot` returns the page's accessibility tree as numbered refs, and `click`
|
|
10
|
+
// takes one of those refs. The agent never needs a screenshot to act, which keeps its context small and its
|
|
11
|
+
// actions deterministic — and it means the model is working from what a screen reader would say, not from what a
|
|
12
|
+
// vision model guesses a JPEG shows. A screenshot is available, for a human to look at, not for the agent to aim by.
|
|
13
|
+
//
|
|
14
|
+
// No dependency: Node's own fetch and WebSocket. There is no npm in the enclave and no network to reach one.
|
|
15
|
+
const CTL = process.env.WB_BROWSE_CTL || 'http://127.0.0.1:9224'
|
|
16
|
+
const GATE = process.env.WB_BROWSE_CDP || 'http://127.0.0.1:9223'
|
|
17
|
+
const out = (o) => { process.stdout.write(JSON.stringify(o) + '\n'); process.exit(o.error ? 1 : 0) }
|
|
18
|
+
|
|
19
|
+
class CDP {
|
|
20
|
+
constructor (ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
|
21
|
+
static async attach () {
|
|
22
|
+
let targets
|
|
23
|
+
try {
|
|
24
|
+
// ★ The gate answers /json with an OBJECT when it has no browser to ask ({ error }) or when the browser did not
|
|
25
|
+
// answer it — a model read "targets.find is not a function" for that (the owner's agent, 2026-10-06). Three quick
|
|
26
|
+
// tries cover a browser that is still coming up; an object after that is the gate's own sentence.
|
|
27
|
+
for (let i = 0; ; i++) {
|
|
28
|
+
targets = await (await fetch(GATE + '/json')).json()
|
|
29
|
+
if (Array.isArray(targets)) break
|
|
30
|
+
if (i >= 2) throw new Error((targets && targets.error) || 'the browser gate answered without a target list')
|
|
31
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
32
|
+
}
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// ★★ SAY WHICH ONE. This used to answer "it may be closed, or the owner has the controls" — two causes,
|
|
35
|
+
// neither checked, and on 2026-09-21 BOTH were false: no browser had ever been started. The agent
|
|
36
|
+
// faithfully passed that on, and the owner went hunting in the viewer for a take-over that did not exist
|
|
37
|
+
// ("Try opening example.com" → "it may have been closed or taken over on your side").
|
|
38
|
+
// The control surface knows exactly which case this is and is already reachable from here, so ask it.
|
|
39
|
+
// An absent capability and a withheld one must never read the same — the third time that lesson has been
|
|
40
|
+
// paid for in this subsystem (the plugin that loaded zero tools, the row that was silent when not running).
|
|
41
|
+
let st = null
|
|
42
|
+
try { st = await (await fetch(CTL + '/status')).json() } catch { /* the control surface is gone too */ }
|
|
43
|
+
if (st && !st.offered) throw new Error('this cloud computer has no browser — it was created without one')
|
|
44
|
+
if (st && st.takeover) throw new Error('the owner has the controls — the agent cannot drive this browser until they give them back')
|
|
45
|
+
if (st && !st.running) throw new Error('no browser is open yet — call browser_open first, then navigate')
|
|
46
|
+
throw new Error('the browser is not reachable, and its control surface did not answer either: ' + String(e).slice(0, 80))
|
|
47
|
+
}
|
|
48
|
+
const t = targets.find((x) => x.type === 'page') || targets[0]
|
|
49
|
+
if (!t) throw new Error('the browser has no page open')
|
|
50
|
+
const ws = new WebSocket(t.webSocketDebuggerUrl)
|
|
51
|
+
await new Promise((res, rej) => {
|
|
52
|
+
const timer = setTimeout(() => rej(new Error('CDP did not answer')), 10000)
|
|
53
|
+
ws.onopen = () => { clearTimeout(timer); res() }
|
|
54
|
+
ws.onerror = () => { clearTimeout(timer); rej(new Error('the CDP gate refused the connection — the owner may have the controls')) }
|
|
55
|
+
})
|
|
56
|
+
const c = new CDP(ws)
|
|
57
|
+
ws.onmessage = (ev) => {
|
|
58
|
+
const m = JSON.parse(ev.data)
|
|
59
|
+
if (m.id && c.pending.has(m.id)) { c.pending.get(m.id)(m); c.pending.delete(m.id) }
|
|
60
|
+
}
|
|
61
|
+
return c
|
|
62
|
+
}
|
|
63
|
+
send (method, params = {}) {
|
|
64
|
+
return new Promise((res, rej) => {
|
|
65
|
+
const i = ++this.id
|
|
66
|
+
const timer = setTimeout(() => rej(new Error(method + ' timed out')), 30000)
|
|
67
|
+
this.pending.set(i, (m) => { clearTimeout(timer); m.error ? rej(new Error(method + ': ' + m.error.message)) : res(m.result) })
|
|
68
|
+
this.ws.send(JSON.stringify({ id: i, method, params }))
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
close () { try { this.ws.close() } catch { /* already gone */ } }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The roles worth showing a model: everything it can act on, plus the text that tells it where it is. A full AX
|
|
75
|
+
// tree of a real page is thousands of nodes and most of them are scaffolding.
|
|
76
|
+
const USEFUL = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'menuitem',
|
|
77
|
+
'tab', 'switch', 'slider', 'option', 'heading', 'StaticText', 'image', 'listitem', 'alert', 'dialog'])
|
|
78
|
+
|
|
79
|
+
async function snapshot (c, limit = 120) {
|
|
80
|
+
await c.send('Accessibility.enable')
|
|
81
|
+
const { nodes } = await c.send('Accessibility.getFullAXTree', { depth: -1 })
|
|
82
|
+
const lines = []
|
|
83
|
+
const refs = {}
|
|
84
|
+
let n = 0
|
|
85
|
+
for (const node of nodes) {
|
|
86
|
+
if (node.ignored) continue
|
|
87
|
+
const role = node.role?.value
|
|
88
|
+
const name = (node.name?.value || '').replace(/\s+/g, ' ').trim()
|
|
89
|
+
if (!role || !USEFUL.has(role)) continue
|
|
90
|
+
if (!name && role !== 'textbox' && role !== 'searchbox') continue
|
|
91
|
+
n++
|
|
92
|
+
if (n > limit) break
|
|
93
|
+
const ref = String(n)
|
|
94
|
+
refs[ref] = node.backendDOMNodeId
|
|
95
|
+
const value = node.properties?.find((p) => p.name === 'value')?.value?.value
|
|
96
|
+
lines.push(`[${ref}] ${role}${name ? ' "' + name.slice(0, 120) + '"' : ''}${value ? ' = "' + String(value).slice(0, 60) + '"' : ''}`)
|
|
97
|
+
}
|
|
98
|
+
return { lines, refs, truncated: n > limit }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The refs are only meaningful next to the snapshot that made them, so they travel with it rather than living in
|
|
102
|
+
// a cache that a page navigation would quietly invalidate.
|
|
103
|
+
const REFS_FILE = (process.env.TMPDIR || '/tmp') + '/wb-browse-refs.json'
|
|
104
|
+
const { writeFileSync, readFileSync } = await import('node:fs')
|
|
105
|
+
|
|
106
|
+
// ★ A MISSING refs file is the SAME situation as a stale ref: the agent has not taken a snapshot yet. Letting
|
|
107
|
+
// readFileSync throw turned that into "ENOENT: no such file or directory", which tells a model nothing it can act
|
|
108
|
+
// on — its own test caught this.
|
|
109
|
+
function loadRefs () {
|
|
110
|
+
try { return JSON.parse(readFileSync(REFS_FILE, 'utf8')) } catch { return {} }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ★ ENTER CARRIES ITS CHARACTER. A keyDown without `text` is a key with no keypress: Chromium moves no form on it, so
|
|
114
|
+
// Enter in a search box never submitted (the owner's agent, 2026-10-07; measured: no submit as shipped, a submit with
|
|
115
|
+
// "\r"). keyDown with the text is what a real keyboard sends.
|
|
116
|
+
async function pressEnter (c) {
|
|
117
|
+
await c.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', unmodifiedText: '\r' })
|
|
118
|
+
await c.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function boxOf (c, backendNodeId) {
|
|
122
|
+
let model
|
|
123
|
+
try { ({ model } = await c.send('DOM.getBoxModel', { backendNodeId })) } catch (e) { throw new Error('that ref is stale — the page changed since the snapshot that made it (' + String(e.message || e).slice(0, 80) + '); take a snapshot first') }
|
|
124
|
+
const q = model.content
|
|
125
|
+
return { x: (q[0] + q[2] + q[4] + q[6]) / 4, y: (q[1] + q[3] + q[5] + q[7]) / 4 }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const op = process.argv[2]
|
|
129
|
+
let args = {}
|
|
130
|
+
try { args = process.argv[3] ? JSON.parse(process.argv[3]) : {} } catch { out({ error: 'arguments must be JSON' }) }
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (op === 'open') {
|
|
134
|
+
const r = await fetch(CTL + '/open', { method: 'POST' })
|
|
135
|
+
const j = await r.json()
|
|
136
|
+
out(j.error ? { error: j.error } : { ok: true, ...j })
|
|
137
|
+
}
|
|
138
|
+
if (op === 'status') out(await (await fetch(CTL + '/status')).json())
|
|
139
|
+
// CLOSE: the browser and everything in it — its tabs, its logins — are gone; the next open starts a fresh one. Refused
|
|
140
|
+
// while the owner holds the controls (browse.go closeCaller): the agent does not pull the screen from a person.
|
|
141
|
+
if (op === 'close') {
|
|
142
|
+
const r = await fetch(CTL + '/close', { method: 'POST' })
|
|
143
|
+
const j = await r.json().catch(() => ({}))
|
|
144
|
+
out(j.error ? { error: j.error } : { ok: true, closed: true })
|
|
145
|
+
}
|
|
146
|
+
// CLEAR (the owner, 2026-10-19: "a tool that can clear browsing history and cookies"): a close IS a clear — it wipes the
|
|
147
|
+
// whole profile, history, cookies, cache and every site's storage (browse.go stopBrowseLocked / forgetBrowser) — and a
|
|
148
|
+
// fresh browser opens after it, so the agent can go on. ⛑ /close answers before it tears down, so this waits until the
|
|
149
|
+
// browser reports it is gone before opening again. Refused, like close, while the owner holds the controls.
|
|
150
|
+
if (op === 'clear') {
|
|
151
|
+
const r = await fetch(CTL + '/close', { method: 'POST' })
|
|
152
|
+
const j = await r.json().catch(() => ({}))
|
|
153
|
+
if (j.error) out({ error: j.error })
|
|
154
|
+
for (let i = 0; i < 40; i++) {
|
|
155
|
+
await new Promise((res) => setTimeout(res, 250))
|
|
156
|
+
const st = await (await fetch(CTL + '/status')).json().catch(() => null)
|
|
157
|
+
if (st && !st.running) break
|
|
158
|
+
}
|
|
159
|
+
const r2 = await fetch(CTL + '/open', { method: 'POST' })
|
|
160
|
+
const j2 = await r2.json().catch(() => ({}))
|
|
161
|
+
out(j2.error ? { error: 'cleared, but the browser did not open again: ' + j2.error } : { ok: true, cleared: true })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ★ NAVIGATE OPENS THE BROWSER when none is open (the owner's agent, 2026-10-07: "it needed browser_open before
|
|
165
|
+
// navigation"). Only a browser this computer OFFERS and that is not in the owner's hands: a computer made without one
|
|
166
|
+
// and a take-over still answer with their own sentences (attach, below).
|
|
167
|
+
let opened = false
|
|
168
|
+
if (op === 'navigate' && args.url) {
|
|
169
|
+
let st = null
|
|
170
|
+
try { st = await (await fetch(CTL + '/status')).json() } catch { /* attach says what is wrong */ }
|
|
171
|
+
if (st && st.offered && !st.running && !st.takeover) {
|
|
172
|
+
const j = await (await fetch(CTL + '/open', { method: 'POST' })).json().catch((e) => ({ error: String(e) }))
|
|
173
|
+
if (j.error) out({ error: 'could not open the browser to navigate: ' + String(j.error).slice(0, 200) })
|
|
174
|
+
opened = true
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const c = await CDP.attach()
|
|
179
|
+
await c.send('Page.enable')
|
|
180
|
+
await c.send('DOM.enable')
|
|
181
|
+
|
|
182
|
+
if (op === 'navigate') {
|
|
183
|
+
if (!args.url) out({ error: 'navigate needs a url' })
|
|
184
|
+
await c.send('Page.navigate', { url: args.url })
|
|
185
|
+
await new Promise((r) => setTimeout(r, args.waitMs ?? 1200))
|
|
186
|
+
} else if (op === 'click') {
|
|
187
|
+
const id = loadRefs()[String(args.ref)]
|
|
188
|
+
if (!id) out({ error: 'no such ref — take a snapshot first, refs belong to the snapshot that made them' })
|
|
189
|
+
const { x, y } = await boxOf(c, id)
|
|
190
|
+
for (const type of ['mousePressed', 'mouseReleased']) {
|
|
191
|
+
await c.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 })
|
|
192
|
+
}
|
|
193
|
+
await new Promise((r) => setTimeout(r, args.waitMs ?? 600))
|
|
194
|
+
} else if (op === 'type') {
|
|
195
|
+
if (args.ref != null) {
|
|
196
|
+
const id = loadRefs()[String(args.ref)]
|
|
197
|
+
if (!id) out({ error: 'no such ref — take a snapshot first, refs belong to the snapshot that made them' })
|
|
198
|
+
const { x, y } = await boxOf(c, id)
|
|
199
|
+
for (const type of ['mousePressed', 'mouseReleased']) {
|
|
200
|
+
await c.send('Input.dispatchMouseEvent', { type, x, y, button: 'left', clickCount: 1 })
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await c.send('Input.insertText', { text: String(args.text ?? '') })
|
|
204
|
+
if (args.enter) {
|
|
205
|
+
await pressEnter(c)
|
|
206
|
+
await new Promise((r) => setTimeout(r, args.waitMs ?? 1000))
|
|
207
|
+
}
|
|
208
|
+
} else if (op === 'key') {
|
|
209
|
+
const key = String(args.key || 'Enter')
|
|
210
|
+
const codes = { Enter: 13, Tab: 9, Escape: 27, Backspace: 8, ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39 }
|
|
211
|
+
if (key === 'Enter') await pressEnter(c)
|
|
212
|
+
else {
|
|
213
|
+
for (const type of ['keyDown', 'keyUp']) {
|
|
214
|
+
await c.send('Input.dispatchKeyEvent', { type, key, code: key, windowsVirtualKeyCode: codes[key] ?? 0 })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
218
|
+
} else if (op === 'back') {
|
|
219
|
+
const h = await c.send('Page.getNavigationHistory')
|
|
220
|
+
const prev = h.entries[h.currentIndex - 1]
|
|
221
|
+
if (!prev) out({ error: 'nothing to go back to' })
|
|
222
|
+
await c.send('Page.navigateToHistoryEntry', { entryId: prev.id })
|
|
223
|
+
await new Promise((r) => setTimeout(r, 900))
|
|
224
|
+
} else if (op === 'scroll') {
|
|
225
|
+
await c.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 200, y: 400, deltaX: 0, deltaY: Number(args.dy ?? 400) })
|
|
226
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
227
|
+
} else if (op === 'screenshot') {
|
|
228
|
+
const { data } = await c.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 })
|
|
229
|
+
const path = args.path || ((process.env.TMPDIR || '/tmp') + '/wb-browse-' + Date.now() + '.jpg')
|
|
230
|
+
writeFileSync(path, Buffer.from(data, 'base64'))
|
|
231
|
+
const { lines } = await snapshot(c, 0).catch(() => ({ lines: [] }))
|
|
232
|
+
c.close()
|
|
233
|
+
out({ ok: true, path, note: 'a picture for a person to look at; act from a snapshot, not from this' })
|
|
234
|
+
} else if (op !== 'snapshot') {
|
|
235
|
+
out({ error: 'unknown op: ' + op })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// every op ends by saying where the browser now is, so the agent never has to ask separately
|
|
239
|
+
const snap = await snapshot(c, args.limit ?? 120)
|
|
240
|
+
writeFileSync(REFS_FILE, JSON.stringify(snap.refs))
|
|
241
|
+
const { root } = await c.send('DOM.getDocument', { depth: 0 })
|
|
242
|
+
const title = await c.send('Runtime.evaluate', { expression: 'document.title', returnByValue: true })
|
|
243
|
+
.then((r) => r.result?.value || '').catch(() => '')
|
|
244
|
+
c.close()
|
|
245
|
+
out({ ok: true, ...(opened ? { opened: true } : {}), url: root.documentURL, title, elements: snap.lines, truncated: snap.truncated })
|
|
246
|
+
} catch (e) {
|
|
247
|
+
out({ error: String(e && e.message ? e.message : e).slice(0, 300) })
|
|
248
|
+
}
|