witbitz-code 1.8.0__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.8.0/src/witbitz_code.egg-info → witbitz_code-1.9.0}/PKG-INFO +1 -1
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/pyproject.toml +1 -1
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/browse.py +47 -5
- witbitz_code-1.9.0/src/witbitz_code/browsetree.py +117 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +9 -5
- witbitz_code-1.9.0/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +303 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui.py +6 -3
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.css +1 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/app.js +23 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ui_static/index.html +9 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/SOURCES.txt +3 -0
- witbitz_code-1.9.0/tests/test_browsetree.py +131 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/LICENSE +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/README.md +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/setup.cfg +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/cli.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_browse.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_images.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_selfupdate.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_ui.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.8.0 → witbitz_code-1.9.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.8.0 → 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"
|
|
@@ -5,11 +5,16 @@ ON by default; `serve --no-browser` or WB_BROWSE_DISABLE=1 turns it off (and rem
|
|
|
5
5
|
programs are JavaScript (the cloud computer's own: browse-local, the agent's CLI, the publisher), so they run with this
|
|
6
6
|
computer's `node` — as the media tools do; without node the agent works without a browser. Each pairing gets a viewer key
|
|
7
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.
|
|
8
12
|
"""
|
|
9
13
|
|
|
10
14
|
from __future__ import annotations
|
|
11
15
|
|
|
12
16
|
import asyncio
|
|
17
|
+
import json
|
|
13
18
|
import os
|
|
14
19
|
import re
|
|
15
20
|
import shutil
|
|
@@ -20,8 +25,8 @@ from typing import Any, Callable
|
|
|
20
25
|
|
|
21
26
|
import httpx
|
|
22
27
|
|
|
23
|
-
from . import plugins
|
|
24
|
-
from .relay import browse_key
|
|
28
|
+
from . import browsetree, plugins
|
|
29
|
+
from .relay import RELAY_URL, browse_key
|
|
25
30
|
|
|
26
31
|
BROWSE_DIR = "witbitz-browse"
|
|
27
32
|
PLUGIN_REL = "plugins/witbitz-browser.js"
|
|
@@ -80,13 +85,16 @@ def _free_port() -> int:
|
|
|
80
85
|
|
|
81
86
|
|
|
82
87
|
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
|
|
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
|
|
85
90
|
self.last: dict | None = None
|
|
86
91
|
self._task: asyncio.Task | None = None
|
|
87
92
|
|
|
88
93
|
def probe(self) -> dict | None:
|
|
89
|
-
"""The browser's last /status — synchronous: the connector's hello must never wait on it.
|
|
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}
|
|
90
98
|
return self.last
|
|
91
99
|
|
|
92
100
|
async def _poll(self) -> None:
|
|
@@ -124,6 +132,10 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
|
|
|
124
132
|
log("witbitz-code: no node on this computer — the agent works without a browser (install Node.js 22+ to add one)")
|
|
125
133
|
return None
|
|
126
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
|
|
127
139
|
watchable = node_major(node) >= 22 # the publisher needs a global WebSocket
|
|
128
140
|
keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
|
|
129
141
|
ctl, gate = _free_port(), _free_port()
|
|
@@ -143,3 +155,33 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
|
|
|
143
155
|
log("witbitz-code: the agent's browser is ready on this computer"
|
|
144
156
|
+ (" — watch it from the Code section" if watchable else " (node is older than 22: the app cannot watch it)") + "; off: serve --no-browser")
|
|
145
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"
|
|
@@ -945,6 +945,7 @@ var tellControl = async (path, body) => {
|
|
|
945
945
|
}
|
|
946
946
|
};
|
|
947
947
|
var said = null;
|
|
948
|
+
var holderVid = null;
|
|
948
949
|
var tellTheViewerWhoHasTheControls = async (why) => {
|
|
949
950
|
let st = null;
|
|
950
951
|
try {
|
|
@@ -954,16 +955,17 @@ var tellTheViewerWhoHasTheControls = async (why) => {
|
|
|
954
955
|
}
|
|
955
956
|
held = !!st.takeover;
|
|
956
957
|
if (!held && cast && cast.incognito && cast.incognito()) closeIncognito("the controls lapsed");
|
|
958
|
+
if (!held) holderVid = null;
|
|
957
959
|
if (!held && cast && cast.controller()) {
|
|
958
960
|
for (const v of castViewers.values()) v.driving = false;
|
|
959
961
|
cast.setController(null).then(assign).catch(() => {
|
|
960
962
|
});
|
|
961
963
|
}
|
|
962
|
-
const now = JSON.stringify({ on: !!st.takeover, web: !!st.web });
|
|
964
|
+
const now = JSON.stringify({ on: !!st.takeover, web: !!st.web, holder: holderVid });
|
|
963
965
|
if (why === "poll" && now === said) return;
|
|
964
966
|
said = now;
|
|
965
967
|
if (relay && relay.readyState === 1) {
|
|
966
|
-
relay.send(await sealCtl(key, { wb: "takeover", on: !!st.takeover, web: !!st.web, audio: !!PULSE, ack: true, cast: !!CDP, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()) }));
|
|
968
|
+
relay.send(await sealCtl(key, { wb: "takeover", on: !!st.takeover, web: !!st.web, ...st.takeover && holderVid ? { holder: holderVid } : {}, audio: !!PULSE, ack: true, cast: !!CDP, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()) }));
|
|
967
969
|
}
|
|
968
970
|
log(
|
|
969
971
|
why === "poll" ? "the state changed \u2014" : "a viewer arrived \u2014",
|
|
@@ -1327,12 +1329,13 @@ function connectRelay() {
|
|
|
1327
1329
|
if (!c.on) await closeIncognito("the controls were given back");
|
|
1328
1330
|
await tellControl("/takeover", { on: !!c.on });
|
|
1329
1331
|
held = !!c.on;
|
|
1332
|
+
holderVid = c.on && typeof c.vid === "string" ? c.vid.slice(0, 64) : null;
|
|
1330
1333
|
log("take-over", c.on ? "ON \u2014 the agent's CDP session is being closed" : "released");
|
|
1331
1334
|
if (cast) await cast.setController(c.on ? norm(c.view) || largestView() : null).catch(() => {
|
|
1332
1335
|
});
|
|
1333
1336
|
for (const [vid, v] of castViewers) v.driving = !!c.on && vid === c.vid;
|
|
1334
1337
|
if (cast) assign();
|
|
1335
|
-
if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "takeover", on: !!c.on, audio: !!PULSE, ack: true, cast: !!cast, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()), close: true }));
|
|
1338
|
+
if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "takeover", on: !!c.on, ...holderVid ? { holder: holderVid } : {}, audio: !!PULSE, ack: true, cast: !!cast, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()), close: true }));
|
|
1336
1339
|
} else if (c.wb === "incognito" && cast) {
|
|
1337
1340
|
if (c.on && !held) {
|
|
1338
1341
|
log("incognito: refused \u2014 nobody holds the controls");
|
|
@@ -1357,12 +1360,13 @@ function connectRelay() {
|
|
|
1357
1360
|
if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "site", mode }));
|
|
1358
1361
|
} else if (c.wb === "hello" && c.cast && typeof c.vid === "string" && CDP) {
|
|
1359
1362
|
const known = castViewers.has(c.vid);
|
|
1360
|
-
|
|
1363
|
+
const mine = held && c.driving && (!holderVid || c.vid.slice(0, 64) === holderVid);
|
|
1364
|
+
castViewers.set(c.vid.slice(0, 64), { view: norm(c.view), at: Date.now(), driving: !!mine });
|
|
1361
1365
|
if (castViewers.size > 8) castViewers.delete(castViewers.keys().next().value);
|
|
1362
1366
|
if (!known) log("cast: a viewer said hello \u2014", JSON.stringify(norm(c.view)));
|
|
1363
1367
|
if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "cast", on: true, close: true }));
|
|
1364
1368
|
await startCast();
|
|
1365
|
-
if (
|
|
1369
|
+
if (mine && cast) await cast.setController(norm(c.view)).catch(() => {
|
|
1366
1370
|
});
|
|
1367
1371
|
assign();
|
|
1368
1372
|
} else if (c.wb === "in" && cast) {
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// GENERATED by tools/browse-embed.mjs from tools/browse-tree/cli.mjs and its pin — do not edit.
|
|
3
|
+
|
|
4
|
+
// tools/browse-tree/pin.json
|
|
5
|
+
var pin_default = {
|
|
6
|
+
_: "THE CLOUD COMPUTER'S BROWSER FOR LINUX that this release installs (tools/browse-tree; make.sh writes it). The files are public at <base><tag>/; both sha256s are checked before anything is unpacked.",
|
|
7
|
+
tag: "p2v",
|
|
8
|
+
base: "https://drwev5vxt2t2e.cloudfront.net/browse-tree/",
|
|
9
|
+
pcr0: "87162d8648af94daec37f9d022c43546f6dc5c9018b7225ee5ccaf3cecd1c7924c7681f0ca48fddb84f5d0a2f987f719",
|
|
10
|
+
commit: "e303d54e2295c148c5177b2216eca21de6b6f3a3",
|
|
11
|
+
tree: {
|
|
12
|
+
file: "tree.tar.zst",
|
|
13
|
+
sha256: "1fceee1d2ddbda8a6ec9f792755a960a877af6342645b8a29fbbf94b28b9536e",
|
|
14
|
+
size: 664712218
|
|
15
|
+
},
|
|
16
|
+
lab: {
|
|
17
|
+
file: "ccvm-lab.bin",
|
|
18
|
+
sha256: "2d15b2e7c93b9626eccf8fa0d728c6e4385aadbbeba538ab45f7123937aed38b",
|
|
19
|
+
size: 11908726
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// tools/browse-tree/run.mjs
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import { existsSync, readFileSync, readlinkSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
var TREE_HOME = () => process.env.WB_BROWSE_TREE_HOME || join(homedir(), ".witbitz", "browse-tree");
|
|
29
|
+
var CTL_PORT = 9224;
|
|
30
|
+
var GATE_PORT = 9223;
|
|
31
|
+
function findTree({ home = TREE_HOME(), platform = process.platform } = {}) {
|
|
32
|
+
if (platform !== "linux") return { why: "the cloud computer's browser runs on Linux only" };
|
|
33
|
+
const cur = join(home, "current");
|
|
34
|
+
if (!existsSync(cur)) return { why: "no cloud browser tree installed (witbitz-code browser install, or node tools/browse-tree/cli.mjs install)" };
|
|
35
|
+
let dir;
|
|
36
|
+
try {
|
|
37
|
+
dir = join(home, readlinkSync(cur));
|
|
38
|
+
} catch {
|
|
39
|
+
dir = cur;
|
|
40
|
+
}
|
|
41
|
+
const m = (() => {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
if (!m || !existsSync(join(dir, "tree", "usr", "bin", "chromium")) || !existsSync(join(dir, "ccvm-lab"))) return { why: `the tree in ${dir} is incomplete \u2014 install it again` };
|
|
49
|
+
if (spawnSync("bwrap", ["--version"], { stdio: "ignore" }).status !== 0) return { why: "bwrap is not installed (apt install bubblewrap)" };
|
|
50
|
+
return { dir, tree: join(dir, "tree"), lab: join(dir, "ccvm-lab"), manifest: m };
|
|
51
|
+
}
|
|
52
|
+
function bwrapArgs({ tree, lab, key, relay }) {
|
|
53
|
+
const env = {
|
|
54
|
+
PATH: "/usr/local/bin:/usr/bin:/bin",
|
|
55
|
+
HOME: "/tmp",
|
|
56
|
+
// the image's own paths (browse_paths.go) — the lab build reads them from here instead of compiling them in
|
|
57
|
+
WB_LAB_ROOT: "/",
|
|
58
|
+
WB_LAB_XKASM: "/usr/bin/Xkasmvnc",
|
|
59
|
+
WB_LAB_CHROMIUM: "/usr/bin/chromium",
|
|
60
|
+
WB_LAB_NODE: "/usr/local/bin/node",
|
|
61
|
+
WB_LAB_PUBLISHER: "/opt/witbitz/browse-publisher.mjs",
|
|
62
|
+
WB_LAB_KASM_WWW: "/usr/share/kasmvnc/www",
|
|
63
|
+
WB_LAB_PULSE: "/usr/bin/pulseaudio",
|
|
64
|
+
WB_LAB_PAREC: "/usr/bin/parec",
|
|
65
|
+
WB_LAB_RRWEB: "/opt/witbitz/rrweb.umd.min.cjs",
|
|
66
|
+
WB_LAB_LOGS: "/tmp",
|
|
67
|
+
WB_LAB_SCRATCH: "/tmp/wb",
|
|
68
|
+
WB_LAB_KEY: key,
|
|
69
|
+
WB_LAB_RELAY: relay
|
|
70
|
+
};
|
|
71
|
+
return [
|
|
72
|
+
"--ro-bind",
|
|
73
|
+
tree,
|
|
74
|
+
"/",
|
|
75
|
+
"--dev",
|
|
76
|
+
"/dev",
|
|
77
|
+
"--proc",
|
|
78
|
+
"/proc",
|
|
79
|
+
"--tmpfs",
|
|
80
|
+
"/tmp",
|
|
81
|
+
"--tmpfs",
|
|
82
|
+
"/run",
|
|
83
|
+
"--ro-bind",
|
|
84
|
+
"/etc/resolv.conf",
|
|
85
|
+
"/run/resolvconf/resolv.conf",
|
|
86
|
+
// the image's /etc/resolv.conf is a link to here
|
|
87
|
+
"--ro-bind",
|
|
88
|
+
lab,
|
|
89
|
+
"/tmp/ccvm-lab",
|
|
90
|
+
"--unshare-pid",
|
|
91
|
+
"--die-with-parent",
|
|
92
|
+
"--clearenv",
|
|
93
|
+
...Object.entries(env).flatMap(([k, v]) => ["--setenv", k, String(v)]),
|
|
94
|
+
"/tmp/ccvm-lab",
|
|
95
|
+
"browselab"
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
async function startTreeBrowser({ found, key, relay, log = console.error, fetchImpl = fetch, cdpCli = "", node = process.execPath } = {}) {
|
|
99
|
+
const child = spawn("bwrap", bwrapArgs({ tree: found.tree, lab: found.lab, key, relay }), { stdio: ["ignore", "ignore", "pipe"], detached: false });
|
|
100
|
+
let tail = "";
|
|
101
|
+
child.stderr.on("data", (d) => {
|
|
102
|
+
tail = (tail + d).slice(-2e3);
|
|
103
|
+
});
|
|
104
|
+
child.on("exit", (code) => log(`witbitz-code: the cloud browser stopped (${code})${tail ? " \u2014 " + tail.trim().split("\n").pop() : ""}`));
|
|
105
|
+
let last = null;
|
|
106
|
+
const poll = async () => {
|
|
107
|
+
try {
|
|
108
|
+
last = await (await fetchImpl(`http://127.0.0.1:${CTL_PORT}/status`, { signal: AbortSignal.timeout(1500) })).json();
|
|
109
|
+
} catch {
|
|
110
|
+
last = null;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
for (let i = 0; i < 20 && !last; i++) {
|
|
114
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
115
|
+
await poll();
|
|
116
|
+
}
|
|
117
|
+
const timer = setInterval(poll, 2e3);
|
|
118
|
+
timer.unref();
|
|
119
|
+
const m = found.manifest || {};
|
|
120
|
+
log(`witbitz-code: the agent's browser is the CLOUD COMPUTER's (${m.tag || "?"}, PCR0 ${String(m.pcr0 || "").slice(0, 16)}\u2026) \u2014 its own Chromium, KasmVNC, PulseAudio and publisher, run here with no enclave, one user and this computer's network; watch it from the Code section`);
|
|
121
|
+
return {
|
|
122
|
+
tag: m.tag || "",
|
|
123
|
+
env: { WB_BROWSE_NODE: node, ...cdpCli ? { WB_BROWSE_CDP_CLI: cdpCli } : {}, WB_BROWSE_CTL: `http://127.0.0.1:${CTL_PORT}`, WB_BROWSE_CDP: `http://127.0.0.1:${GATE_PORT}` },
|
|
124
|
+
// `local`: the app watches a computer's browser through a key it derives from the pairing (opencodeApp.browseCloudOf), as
|
|
125
|
+
// for the laptop browser; `cast`: the publisher screencasts, as there; `tree`: which image this is, for whoever debugs it
|
|
126
|
+
probe: () => last ? { ...last, local: true, cast: true, tree: m.tag || "" } : null,
|
|
127
|
+
stop: () => {
|
|
128
|
+
clearInterval(timer);
|
|
129
|
+
try {
|
|
130
|
+
child.kill();
|
|
131
|
+
} catch {
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// tools/browse-tree/install.mjs
|
|
138
|
+
import { spawn as spawn2, spawnSync as spawnSync2 } from "node:child_process";
|
|
139
|
+
import { createHash } from "node:crypto";
|
|
140
|
+
import { chmodSync, copyFileSync, createReadStream, createWriteStream, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
141
|
+
import { join as join2 } from "node:path";
|
|
142
|
+
import { Readable, Transform } from "node:stream";
|
|
143
|
+
import { pipeline } from "node:stream/promises";
|
|
144
|
+
import zlib from "node:zlib";
|
|
145
|
+
var sha256File = (file) => new Promise((resolve, reject) => {
|
|
146
|
+
const h = createHash("sha256");
|
|
147
|
+
createReadStream(file).on("data", (d) => h.update(d)).on("end", () => resolve(h.digest("hex"))).on("error", reject);
|
|
148
|
+
});
|
|
149
|
+
async function download(url, dest, onBytes, fetchImpl) {
|
|
150
|
+
const r = await fetchImpl(url);
|
|
151
|
+
if (!r.ok || !r.body) throw new Error(`${url} answered ${r.status}`);
|
|
152
|
+
const h = createHash("sha256");
|
|
153
|
+
const tap = new Transform({ transform(chunk, _e, cb) {
|
|
154
|
+
h.update(chunk);
|
|
155
|
+
onBytes(chunk.length);
|
|
156
|
+
cb(null, chunk);
|
|
157
|
+
} });
|
|
158
|
+
await pipeline(Readable.fromWeb(r.body), tap, createWriteStream(dest));
|
|
159
|
+
return h.digest("hex");
|
|
160
|
+
}
|
|
161
|
+
async function unpack(file, dir) {
|
|
162
|
+
const tar = spawn2("tar", ["-x", "--no-same-owner", "-C", dir], { stdio: ["pipe", "ignore", "pipe"] });
|
|
163
|
+
let err = "";
|
|
164
|
+
tar.stderr.on("data", (d) => {
|
|
165
|
+
err = (err + d).slice(-500);
|
|
166
|
+
});
|
|
167
|
+
const done = new Promise((resolve, reject) => tar.on("close", (c) => c === 0 ? resolve() : reject(new Error("tar: " + (err.trim() || "exit " + c)))));
|
|
168
|
+
if (typeof zlib.createZstdDecompress === "function") await pipeline(createReadStream(file), zlib.createZstdDecompress(), tar.stdin);
|
|
169
|
+
else {
|
|
170
|
+
const z = spawn2("zstd", ["-dc", file], { stdio: ["ignore", "pipe", "inherit"] });
|
|
171
|
+
z.on("error", () => tar.kill());
|
|
172
|
+
await pipeline(z.stdout, tar.stdin);
|
|
173
|
+
}
|
|
174
|
+
await done;
|
|
175
|
+
}
|
|
176
|
+
async function installTree({ pin, from = "", home = TREE_HOME(), onProgress = () => {
|
|
177
|
+
}, fetchImpl = fetch } = {}) {
|
|
178
|
+
if (process.platform !== "linux") throw new Error("the cloud computer's browser runs on Linux only");
|
|
179
|
+
if (spawnSync2("tar", ["--version"], { stdio: "ignore" }).status !== 0) throw new Error("tar is not installed");
|
|
180
|
+
if (typeof zlib.createZstdDecompress !== "function" && spawnSync2("zstd", ["--version"], { stdio: "ignore" }).status !== 0) throw new Error("this Node has no zstd and the zstd program is not installed \u2014 update Node to 22.15+, or install zstd");
|
|
181
|
+
const want = from ? JSON.parse(readFileSync2(join2(from, "manifest.json"), "utf8")) : pin;
|
|
182
|
+
if (!want || !/^p\d\w*$/.test(String(want.tag || "")) || !want.tree || !want.lab) throw new Error("no browser is pinned in this release");
|
|
183
|
+
mkdirSync(home, { recursive: true });
|
|
184
|
+
const stage = join2(home, ".incoming");
|
|
185
|
+
rmSync(stage, { recursive: true, force: true });
|
|
186
|
+
mkdirSync(stage, { recursive: true });
|
|
187
|
+
try {
|
|
188
|
+
const parts = [["tree", "tree.tar.zst"], ["lab", "ccvm-lab"]].map(([k, f]) => [k, f, want[k] && want[k].file || f]);
|
|
189
|
+
const total = parts.reduce((n, [k, , src]) => n + Number(want[k] && want[k].size || (from ? statSync(join2(from, src)).size : 0)), 0);
|
|
190
|
+
let done = 0;
|
|
191
|
+
for (const [k, f, src] of parts) {
|
|
192
|
+
let got;
|
|
193
|
+
if (from) {
|
|
194
|
+
copyFileSync(join2(from, src), join2(stage, f));
|
|
195
|
+
got = await sha256File(join2(stage, f));
|
|
196
|
+
done += statSync(join2(stage, f)).size;
|
|
197
|
+
onProgress({ step: "download", done, total });
|
|
198
|
+
} else got = await download(`${want.base}${want.tag}/${src}`, join2(stage, f), (n) => {
|
|
199
|
+
done += n;
|
|
200
|
+
onProgress({ step: "download", done, total });
|
|
201
|
+
}, fetchImpl);
|
|
202
|
+
onProgress({ step: "check", done, total });
|
|
203
|
+
if (got !== want[k].sha256) throw new Error(`${f} does not match what this release pins (${got.slice(0, 12)}\u2026 \u2260 ${String(want[k].sha256).slice(0, 12)}\u2026) \u2014 nothing was installed`);
|
|
204
|
+
}
|
|
205
|
+
const dir = join2(home, want.tag), next = dir + ".new";
|
|
206
|
+
rmSync(next, { recursive: true, force: true });
|
|
207
|
+
mkdirSync(join2(next, "tree"), { recursive: true });
|
|
208
|
+
onProgress({ step: "unpack", done, total });
|
|
209
|
+
await unpack(join2(stage, "tree.tar.zst"), join2(next, "tree"));
|
|
210
|
+
if (!existsSync2(join2(next, "tree", "usr", "bin", "chromium"))) throw new Error("the unpacked tree has no Chromium");
|
|
211
|
+
copyFileSync(join2(stage, "ccvm-lab"), join2(next, "ccvm-lab"));
|
|
212
|
+
chmodSync(join2(next, "ccvm-lab"), 493);
|
|
213
|
+
const { _: _note, base: _base, ...record } = want;
|
|
214
|
+
writeFileSync(join2(next, "manifest.json"), JSON.stringify(record, null, 1) + "\n");
|
|
215
|
+
rmSync(dir, { recursive: true, force: true });
|
|
216
|
+
renameSync(next, dir);
|
|
217
|
+
const cur = join2(home, "current");
|
|
218
|
+
try {
|
|
219
|
+
unlinkSync(cur);
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
symlinkSync(want.tag, cur);
|
|
223
|
+
onProgress({ step: "done", done: total, total });
|
|
224
|
+
return { tag: want.tag, dir };
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(stage, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
var has = (cmd2) => spawnSync2(cmd2, ["--version"], { stdio: "ignore" }).status === 0;
|
|
230
|
+
function treeStatus(pin) {
|
|
231
|
+
const linux = process.platform === "linux";
|
|
232
|
+
const needs = !linux ? ["Linux"] : [
|
|
233
|
+
...has("bwrap") ? [] : ["bwrap (apt install bubblewrap)"],
|
|
234
|
+
...has("tar") ? [] : ["tar"],
|
|
235
|
+
...typeof zlib.createZstdDecompress === "function" || has("zstd") ? [] : ["Node 22.15+ or the zstd program"]
|
|
236
|
+
];
|
|
237
|
+
const found = linux ? findTree() : { why: "Linux only" };
|
|
238
|
+
const installed = found.manifest ? { tag: found.manifest.tag, pcr0: found.manifest.pcr0 } : null;
|
|
239
|
+
return {
|
|
240
|
+
linux,
|
|
241
|
+
needs,
|
|
242
|
+
supported: linux && !needs.length,
|
|
243
|
+
pinned: { tag: pin.tag, pcr0: pin.pcr0, bytes: Number(pin.tree.size || 0) + Number(pin.lab.size || 0) },
|
|
244
|
+
installed,
|
|
245
|
+
current: !!(installed && installed.tag === pin.tag),
|
|
246
|
+
why: found.why || ""
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// tools/browse-tree/cli.mjs
|
|
251
|
+
var arg = (name) => {
|
|
252
|
+
const a = process.argv, i = a.indexOf(name);
|
|
253
|
+
return i >= 0 ? a[i + 1] : "";
|
|
254
|
+
};
|
|
255
|
+
var say = (o) => process.stdout.write(JSON.stringify(o) + "\n");
|
|
256
|
+
var cmd = process.argv[2];
|
|
257
|
+
if (cmd === "status") say(treeStatus(pin_default));
|
|
258
|
+
else if (cmd === "install") {
|
|
259
|
+
const json = process.argv.includes("--json");
|
|
260
|
+
let last = -1;
|
|
261
|
+
try {
|
|
262
|
+
const r = await installTree({ pin: pin_default, from: arg("--from"), onProgress: (p) => {
|
|
263
|
+
const pct = p.total ? Math.floor(p.done * 1e3 / p.total) / 10 : 0;
|
|
264
|
+
if (pct === last && p.step === "download") return;
|
|
265
|
+
last = pct;
|
|
266
|
+
if (json) say({ ...p, pct });
|
|
267
|
+
else process.stderr.write(`\r${p.step} ${pct}% `);
|
|
268
|
+
} });
|
|
269
|
+
if (json) say({ ok: true, tag: r.tag });
|
|
270
|
+
else console.error(`
|
|
271
|
+
\u2713 ${r.tag}'s browser is installed \u2014 witbitz-code serve uses it`);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
const m = String(e && e.message || e);
|
|
274
|
+
if (json) say({ error: m });
|
|
275
|
+
else console.error("\n\u2717 " + m);
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
} else if (cmd === "run") {
|
|
279
|
+
const found = findTree();
|
|
280
|
+
if (!found.dir) {
|
|
281
|
+
say({ error: found.why });
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
const b = await startTreeBrowser({ found, key: arg("--key") || process.env.WB_BROWSE_KEY || "", relay: arg("--relay"), log: (m) => console.error(m) });
|
|
285
|
+
say({ ready: true, tag: b.tag, ...b.env });
|
|
286
|
+
const parent = Number(arg("--parent")) || 0;
|
|
287
|
+
const bye = () => {
|
|
288
|
+
b.stop();
|
|
289
|
+
process.exit(0);
|
|
290
|
+
};
|
|
291
|
+
process.on("SIGTERM", bye);
|
|
292
|
+
process.on("SIGINT", bye);
|
|
293
|
+
if (parent) setInterval(() => {
|
|
294
|
+
try {
|
|
295
|
+
process.kill(parent, 0);
|
|
296
|
+
} catch {
|
|
297
|
+
bye();
|
|
298
|
+
}
|
|
299
|
+
}, 2e3);
|
|
300
|
+
} else if (cmd) {
|
|
301
|
+
console.error("usage: status | install [--json] | run --key <k> --relay <url> [--parent <pid>]");
|
|
302
|
+
process.exit(2);
|
|
303
|
+
}
|
|
@@ -35,7 +35,7 @@ from importlib import resources
|
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
from typing import Any, Awaitable, Callable
|
|
37
37
|
|
|
38
|
-
from . import __version__, devicelink, link, net, ports, qr, seccheck, selfupdate, uitools, uninstall, winbg, wizard, wslstart
|
|
38
|
+
from . import __version__, browsetree, devicelink, link, net, ports, qr, seccheck, selfupdate, uitools, uninstall, winbg, wizard, wslstart
|
|
39
39
|
from . import pair as pair_cmd
|
|
40
40
|
from . import pairings as pp
|
|
41
41
|
|
|
@@ -127,11 +127,12 @@ class Manager:
|
|
|
127
127
|
login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
|
|
128
128
|
login_disable: Callable[[dict], bool] = wslstart.startup_disable,
|
|
129
129
|
port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
|
|
130
|
-
tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None,
|
|
130
|
+
tools: uitools.Tools | None = None, tree: browsetree.Tree | None = None, label: dict | None = None, log: deque | None = None,
|
|
131
131
|
security: seccheck.SecurityCheck | None = None, updates: selfupdate.Updates | None = None) -> None:
|
|
132
132
|
self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
|
|
133
133
|
self._load, self._keys_line = load_pairings, keys_line
|
|
134
134
|
self.tools = tools or uitools.Tools()
|
|
135
|
+
self.tree = tree or browsetree.Tree()
|
|
135
136
|
self.security = security or seccheck.SecurityCheck()
|
|
136
137
|
self.updates = updates or selfupdate.Updates(fetch_text=_fetch_text)
|
|
137
138
|
self._owners = port_owners or ports.port_owners
|
|
@@ -219,7 +220,8 @@ class Manager:
|
|
|
219
220
|
asyncio.ensure_future(self.updates.check(fresh=fresh))
|
|
220
221
|
return {"version": __version__, "update": self.updates.status(), "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
|
|
221
222
|
"login": self.login(), "busy": self.busy, "background": self.background,
|
|
222
|
-
"tools": await asyncio.to_thread(self.tools.status),
|
|
223
|
+
"tools": await asyncio.to_thread(self.tools.status),
|
|
224
|
+
"tree": await asyncio.to_thread(self.tree.status), "security": self.security.peek(), "log": list(self.log)[-14:]}
|
|
223
225
|
|
|
224
226
|
async def elsewhere(self, fresh: bool = False) -> dict | None:
|
|
225
227
|
"""{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
|
|
@@ -549,6 +551,7 @@ class Server:
|
|
|
549
551
|
"/api/quit": self.m.quit,
|
|
550
552
|
"/api/update": self.m.update,
|
|
551
553
|
"/api/tools/install": self.m.tools.install,
|
|
554
|
+
"/api/tree/install": self.m.tree.install,
|
|
552
555
|
"/api/security/fix": lambda: self.m.security.fix(str(data.get("id") or "")),
|
|
553
556
|
}
|
|
554
557
|
act = actions.get(path)
|
|
@@ -59,3 +59,4 @@ p.small, div.small { font-size: 12.5px; }
|
|
|
59
59
|
.sec-list li.todo .sec-head span { font-weight: 600; }
|
|
60
60
|
.sec-list li.ok { opacity: .7; }
|
|
61
61
|
.sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
62
|
+
progress { width: 100%; height: 8px; margin-top: 10px; accent-color: var(--accent); }
|