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.
Files changed (117) hide show
  1. {witbitz_code-1.7.4/src/witbitz_code.egg-info → witbitz_code-1.9.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/__init__.py +1 -1
  4. witbitz_code-1.9.0/src/witbitz_code/browse.py +187 -0
  5. witbitz_code-1.9.0/src/witbitz_code/browsetree.py +117 -0
  6. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/cli.py +18 -3
  7. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/codetools.py +1 -1
  8. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/connector.py +27 -4
  9. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/devicelink.py +4 -0
  10. witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +248 -0
  11. witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +223 -0
  12. witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +1399 -0
  13. witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +303 -0
  14. witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browser.js +168 -0
  15. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/relay.py +10 -0
  16. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/selfupdate.py +5 -4
  17. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui.py +10 -6
  18. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.css +1 -0
  19. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.js +27 -4
  20. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/index.html +9 -0
  21. {witbitz_code-1.7.4 → witbitz_code-1.9.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  22. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/SOURCES.txt +9 -0
  23. witbitz_code-1.9.0/tests/test_browse.py +43 -0
  24. witbitz_code-1.9.0/tests/test_browsetree.py +131 -0
  25. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_devicelink.py +3 -1
  26. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_selfupdate.py +14 -1
  27. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_ui.py +28 -1
  28. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/LICENSE +0 -0
  29. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/README.md +0 -0
  30. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/setup.cfg +0 -0
  31. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/__main__.py +0 -0
  32. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/_js.py +0 -0
  33. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/account.py +0 -0
  34. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/asks.py +0 -0
  35. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/attachments.py +0 -0
  36. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/auto.py +0 -0
  37. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/auto_runner.py +0 -0
  38. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/codekeys.py +0 -0
  39. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/compress.py +0 -0
  40. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/computers.py +0 -0
  41. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/confidential.py +0 -0
  42. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/confidential_models.json +0 -0
  43. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/dirs.py +0 -0
  44. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/folders.py +0 -0
  45. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/gateway.py +0 -0
  46. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  47. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  48. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/images.py +0 -0
  49. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/keys.py +0 -0
  50. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/link.py +0 -0
  51. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/mkdir.py +0 -0
  52. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/models.py +0 -0
  53. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/net.py +0 -0
  54. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/offered_models.json +0 -0
  55. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  56. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  57. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  58. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  59. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/outputs.py +0 -0
  60. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/pair.py +0 -0
  61. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/pairings.py +0 -0
  62. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/payers.py +0 -0
  63. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/plugins.py +0 -0
  64. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/policy.py +0 -0
  65. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/ports.py +0 -0
  66. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/qr.py +0 -0
  67. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/receipt.py +0 -0
  68. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/remind.py +0 -0
  69. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/seccheck.py +0 -0
  70. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/seen.py +0 -0
  71. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/tinfoil_read.py +0 -0
  72. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/tools_catalog.json +0 -0
  73. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/tools_probe.py +0 -0
  74. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/toolsetup.py +0 -0
  75. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/uitools.py +0 -0
  76. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/uninstall.py +0 -0
  77. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/winagent.py +0 -0
  78. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/winbg.py +0 -0
  79. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/winconsole.py +0 -0
  80. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/wizard.py +0 -0
  81. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code/wslstart.py +0 -0
  82. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  83. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  84. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  85. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  86. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_asks.py +0 -0
  87. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_attachments.py +0 -0
  88. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_auto.py +0 -0
  89. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_cli.py +0 -0
  90. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_codekeys.py +0 -0
  91. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_codetools.py +0 -0
  92. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_confidential.py +0 -0
  93. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_connector_e2e.py +0 -0
  94. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_dirs.py +0 -0
  95. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_files_and_compress.py +0 -0
  96. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_folders.py +0 -0
  97. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_images.py +0 -0
  98. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_outputs.py +0 -0
  99. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_pair_flows.py +0 -0
  100. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_plugins.py +0 -0
  101. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_policy.py +0 -0
  102. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_ports.py +0 -0
  103. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_ports_win.py +0 -0
  104. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_registry.py +0 -0
  105. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_relay_codec.py +0 -0
  106. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_remind.py +0 -0
  107. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_seccheck.py +0 -0
  108. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_setup.py +0 -0
  109. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_tools_probe.py +0 -0
  110. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_toolsetup.py +0 -0
  111. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_uitools.py +0 -0
  112. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_uninstall.py +0 -0
  113. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_winagent.py +0 -0
  114. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_winbg.py +0 -0
  115. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_winconsole.py +0 -0
  116. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_windows_files.py +0 -0
  117. {witbitz_code-1.7.4 → witbitz_code-1.9.0}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.4
3
+ Version: 1.9.0
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.7.4"
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"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.7.4"
7
+ __version__ = "1.9.0"
@@ -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) -> 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) -> Connector:
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