witbitz-code 1.11.0__tar.gz → 1.13.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.11.0/src/witbitz_code.egg-info → witbitz_code-1.13.0}/PKG-INFO +1 -1
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/pyproject.toml +1 -1
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/browse.py +12 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/cli.py +5 -2
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/codekeys.py +55 -7
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/connector.py +13 -4
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/devicelink.py +18 -3
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/link.py +23 -1
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +37 -5
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/pair.py +17 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- witbitz_code-1.13.0/tests/test_browse.py +97 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_codekeys.py +35 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_pair_flows.py +32 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_selfupdate.py +19 -0
- witbitz_code-1.11.0/tests/test_browse.py +0 -43
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/LICENSE +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/README.md +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/setup.cfg +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/app.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/ui.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/ui_static/app.js +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code.egg-info/SOURCES.txt +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_images.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_ui.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_ui_page.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.11.0 → witbitz_code-1.13.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.13.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"
|
|
@@ -97,6 +97,18 @@ class LocalBrowser:
|
|
|
97
97
|
return {**self.last, "local": True, "cast": True, "tree": self.tree}
|
|
98
98
|
return self.last
|
|
99
99
|
|
|
100
|
+
async def set_private(self, on: bool) -> bool:
|
|
101
|
+
"""Private browsing on/off (the app's switch, through the connector): browse-local.mjs keeps it and wipes the profile.
|
|
102
|
+
The cloud computer's browser (a tree) takes it from its own bundle, not from here."""
|
|
103
|
+
if self.tree:
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
r = await self._http.post(f"http://127.0.0.1:{self.ctl}/private", json={"on": bool(on)}, timeout=5)
|
|
107
|
+
self.last = r.json() if r.status_code == 200 else self.last
|
|
108
|
+
except (httpx.HTTPError, ValueError):
|
|
109
|
+
await self._poll()
|
|
110
|
+
return bool(self.last and self.last.get("private") is bool(on))
|
|
111
|
+
|
|
100
112
|
async def _poll(self) -> None:
|
|
101
113
|
try:
|
|
102
114
|
r = await self._http.get(f"http://127.0.0.1:{self.ctl}/status", timeout=1.5)
|
|
@@ -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] [--no-browser]
|
|
44
|
+
serve [--port <n>] [--no-opencode] [--no-browser] [--private-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
|
|
@@ -272,6 +272,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
272
272
|
if browse_mod.disabled(args):
|
|
273
273
|
browse_mod.remove()
|
|
274
274
|
else:
|
|
275
|
+
if "--private-browser" in args:
|
|
276
|
+
os.environ["WB_BROWSE_PRIVATE"] = "1" # private browsing on (browse-local.mjs keeps it; the app's switch turns it off)
|
|
275
277
|
try:
|
|
276
278
|
browser = await browse_mod.start(pairings, log=_err, profile=(store / "browse-profile") if store is not None else None)
|
|
277
279
|
except OSError as e:
|
|
@@ -296,7 +298,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
|
|
|
296
298
|
_err("witbitz-code: model calls are paid by each account's own coding keys; confidential models are enforced (min_privacy + verified receipts)")
|
|
297
299
|
# Auto mode's reviewer asks the policy of the model proxy directly (auto_runner.REVIEW_MODELS), billed like the session.
|
|
298
300
|
connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else "",
|
|
299
|
-
browse_probe=browser.probe if browser else None
|
|
301
|
+
browse_probe=browser.probe if browser else None,
|
|
302
|
+
browse_private=browser.set_private if browser else None)
|
|
300
303
|
live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
|
|
301
304
|
live["connector"] = connector
|
|
302
305
|
if live_out is not None:
|
|
@@ -26,6 +26,7 @@ from cryptography.hazmat.primitives.asymmetric import ec, ed25519
|
|
|
26
26
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
27
27
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
28
28
|
|
|
29
|
+
from . import app as app_mod
|
|
29
30
|
from . import pairings as pp
|
|
30
31
|
|
|
31
32
|
API = "https://api.witbitz.chat/v1/spaces/code-keys"
|
|
@@ -194,7 +195,7 @@ def sign_in_left_s(token: str, now: float | None = None) -> float:
|
|
|
194
195
|
|
|
195
196
|
|
|
196
197
|
async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, computer_id: str, name: str,
|
|
197
|
-
ident: dict | None = None) -> dict:
|
|
198
|
+
ident: dict | None = None, app: str | None = None) -> dict:
|
|
198
199
|
"""Ask the account for this computer's keys → the entry to save. Raises CodeKeyRefusal with a sentence."""
|
|
199
200
|
if not id_token:
|
|
200
201
|
raise CodeKeyRefusal("not_signed_in", REFUSALS["not_signed_in"])
|
|
@@ -202,19 +203,28 @@ async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, comput
|
|
|
202
203
|
raise CodeKeyRefusal("signin_stale", REFUSALS["signin_stale"])
|
|
203
204
|
ident = ident or new_identity()
|
|
204
205
|
try:
|
|
206
|
+
# `app`: the Spaces app this pairing belongs to — each app keeps its own computers and allowance (the owner,
|
|
207
|
+
# 2026-10-23: "they should be completely separate"); the server files the keys under that app's tenant.
|
|
205
208
|
r = await client.post(api_base(), json={"idToken": id_token, "computerId": computer_id, "name": name,
|
|
206
|
-
"sealPub": seal_pub(ident), "signPub": sign_pub(ident)}, timeout=30)
|
|
209
|
+
"sealPub": seal_pub(ident), "signPub": sign_pub(ident), "app": app or app_mod.name()}, timeout=30)
|
|
207
210
|
except httpx.HTTPError as e:
|
|
208
211
|
raise CodeKeyRefusal("unreachable", f"could not reach Witbitz to get this computer's keys ({str(e)[:80]})") from e
|
|
209
212
|
if r.status_code != 200:
|
|
210
213
|
raise _refusal(r, account)
|
|
211
214
|
d = (r.json() or {}).get("data") or {}
|
|
212
|
-
entry = {"account": account, "computerId": computer_id, "name": name, "
|
|
213
|
-
|
|
215
|
+
entry = {"account": account, "computerId": computer_id, "name": name, **ident, "app": app or app_mod.name(), "at": int(time.time() * 1000)}
|
|
216
|
+
return _take(entry, d)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _take(entry: dict, d: dict) -> dict:
|
|
220
|
+
"""A minted pair (a pairing, or a move to the computer's own app) into `entry`: its ids, caps and keys opened with the
|
|
221
|
+
computer's own identity. Raises CodeKeyRefusal when they cannot be opened."""
|
|
222
|
+
entry.update({"keyId": d.get("keyId"), "tenantId": d.get("tenantId"), "userId": d.get("userId"), "kid": d.get("kid"), "caps": d.get("caps") or {}})
|
|
223
|
+
entry.pop("tinfoil", None)
|
|
214
224
|
sealed = d.get("sealed") or {}
|
|
215
225
|
for provider in PROVIDERS:
|
|
216
226
|
if sealed.get(provider):
|
|
217
|
-
secret = open_sealed(sealed[provider],
|
|
227
|
+
secret = open_sealed(sealed[provider], entry, aad(entry["tenantId"], entry["userId"], entry["kid"], provider))
|
|
218
228
|
if secret is None:
|
|
219
229
|
raise CodeKeyRefusal("unsealable", "the keys Witbitz sent could not be opened on this computer — pair again")
|
|
220
230
|
entry[provider] = secret
|
|
@@ -223,6 +233,18 @@ async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, comput
|
|
|
223
233
|
return entry
|
|
224
234
|
|
|
225
235
|
|
|
236
|
+
def app_of(entry: dict) -> str:
|
|
237
|
+
"""The Spaces app these keys are for: recorded at the mint since 2026-10-23; before that, the app of the pairing they
|
|
238
|
+
belong to (its `origin`), else this install's."""
|
|
239
|
+
if entry.get("app") in (app_mod.REGULAR, app_mod.VAULTED):
|
|
240
|
+
return entry["app"]
|
|
241
|
+
try:
|
|
242
|
+
p = next((x for x in pp.read_pairings()["pairings"] if x.get("computerId") == entry.get("computerId")), None)
|
|
243
|
+
except Exception: # noqa: BLE001 — a reading; the install's own app is the answer then
|
|
244
|
+
p = None
|
|
245
|
+
return app_mod.of_origin(p.get("origin")) if p and p.get("origin") else app_mod.name()
|
|
246
|
+
|
|
247
|
+
|
|
226
248
|
def _server_time(r: httpx.Response) -> float | None:
|
|
227
249
|
try:
|
|
228
250
|
return parsedate_to_datetime(r.headers.get("date") or "").timestamp()
|
|
@@ -230,14 +252,25 @@ def _server_time(r: httpx.Response) -> float | None:
|
|
|
230
252
|
return None
|
|
231
253
|
|
|
232
254
|
|
|
255
|
+
def _status_body(entry: dict) -> dict:
|
|
256
|
+
"""The status call, naming the app these keys are for and signing a move there ('move:<app>'): a computer the server has
|
|
257
|
+
under the other app's tenant moves to its own (every computer paired before 2026-10-23 was under the hosted app's)."""
|
|
258
|
+
b = signed_body("status", entry)
|
|
259
|
+
a = app_of(entry)
|
|
260
|
+
b["app"] = a
|
|
261
|
+
b["moveSig"] = _b64u(_sign_key(entry).sign(signed_message("move:" + a, entry["keyId"], b["ts"]).encode()))
|
|
262
|
+
return b
|
|
263
|
+
|
|
264
|
+
|
|
233
265
|
async def _signed_post(client: httpx.AsyncClient, path: str, op: str, entry: dict, timeout: float = 10) -> httpx.Response:
|
|
234
266
|
"""A signed call; refused while this computer's clock is off the server's, it is signed again to the server's time."""
|
|
235
|
-
|
|
267
|
+
body = (lambda: _status_body(entry)) if op == "status" else (lambda: signed_body(op, entry))
|
|
268
|
+
r = await client.post(api_base() + path, json=body(), timeout=timeout)
|
|
236
269
|
if r.status_code == 401:
|
|
237
270
|
server = _server_time(r)
|
|
238
271
|
if server is not None and abs(server - _now()) > SKEW_FIX_S:
|
|
239
272
|
_clock["skew"] = server - time.time()
|
|
240
|
-
r = await client.post(api_base() + path, json=
|
|
273
|
+
r = await client.post(api_base() + path, json=body(), timeout=timeout)
|
|
241
274
|
return r
|
|
242
275
|
|
|
243
276
|
|
|
@@ -259,6 +292,19 @@ async def status(client: httpx.AsyncClient, entry: dict) -> dict:
|
|
|
259
292
|
if r.status_code != 200:
|
|
260
293
|
return {"live": None, "why": _why(r)} # unknown — keep going as before, and say why
|
|
261
294
|
d = (r.json() or {}).get("data") or {}
|
|
295
|
+
moved = d.get("moved")
|
|
296
|
+
if isinstance(moved, dict) and moved.get("keyId"):
|
|
297
|
+
# MOVED TO ITS OWN APP: the server retired the old pair, so the new one is taken and SAVED here, whoever asked
|
|
298
|
+
# (serve's check, the page, `pair --status`) — keys left unsaved would be keys this computer no longer has.
|
|
299
|
+
try:
|
|
300
|
+
_take(entry, moved)
|
|
301
|
+
entry["app"] = app_of(entry)
|
|
302
|
+
save(entry)
|
|
303
|
+
d["changed"] = False # saved already
|
|
304
|
+
d["movedTo"] = entry["app"]
|
|
305
|
+
except CodeKeyRefusal as e:
|
|
306
|
+
return {"live": None, "why": str(e)}
|
|
307
|
+
return d
|
|
262
308
|
tf = (d.get("sealed") or {}).get("tinfoil")
|
|
263
309
|
if tf:
|
|
264
310
|
secret = open_sealed(tf, entry, aad(entry["tenantId"], entry["userId"], entry["kid"], "tinfoil"))
|
|
@@ -354,6 +400,8 @@ class LiveKeys:
|
|
|
354
400
|
say(f'witbitz-code: coding for {e.get("account")} is paused by Witbitz')
|
|
355
401
|
self.refuse[cid] = PAUSED_TEXT
|
|
356
402
|
elif st.get("live") is True:
|
|
403
|
+
if st.get("movedTo"):
|
|
404
|
+
say(f'witbitz-code: "{e.get("name")}" → {e.get("account")} now codes under the {app_mod.label(st["movedTo"])}\'s own allowance')
|
|
357
405
|
if self.refuse.pop(cid, None):
|
|
358
406
|
say(f'witbitz-code: coding for {e.get("account")} is back on')
|
|
359
407
|
if st.get("changed"):
|
|
@@ -22,7 +22,7 @@ import re
|
|
|
22
22
|
import sys
|
|
23
23
|
import time
|
|
24
24
|
import urllib.parse
|
|
25
|
-
from collections.abc import Callable
|
|
25
|
+
from collections.abc import Awaitable, Callable
|
|
26
26
|
from dataclasses import dataclass, field
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
from typing import Any
|
|
@@ -162,8 +162,10 @@ class PairingServer:
|
|
|
162
162
|
asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
|
|
163
163
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
164
164
|
model_url: str = "", remind_times: dict | None = None,
|
|
165
|
-
browse_probe: Callable[[], dict | None] | None = None
|
|
165
|
+
browse_probe: Callable[[], dict | None] | None = None,
|
|
166
|
+
browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> None:
|
|
166
167
|
self.pairing = pairing
|
|
168
|
+
self._browse_private = browse_private # private browsing on/off (browse.py LocalBrowser.set_private)
|
|
167
169
|
# THE AGENT'S BROWSER ON THIS COMPUTER (browse.py): its /status rides the hello; a change is announced within 2 s
|
|
168
170
|
self._browse_probe = browse_probe
|
|
169
171
|
self._browse_said = "" # the last browse report the page was told, as JSON
|
|
@@ -355,6 +357,12 @@ class PairingServer:
|
|
|
355
357
|
# Which suggested tools this computer has (spaces/public/codeTools.js) — a PATH lookup, nonce-checked like a request.
|
|
356
358
|
if current:
|
|
357
359
|
await self.peer.send({"t": "tools", **probe_tools()})
|
|
360
|
+
elif t == "browse":
|
|
361
|
+
# PRIVATE BROWSING for the agent's browser on this computer (browse-local.mjs wipes its profile at every open and
|
|
362
|
+
# close). Nonce-checked like a request; only a browser that says `canPrivate` is offered the switch by the page.
|
|
363
|
+
if current and self._browse_private and isinstance(m.get("private"), bool):
|
|
364
|
+
await self._browse_private(m["private"])
|
|
365
|
+
self.hello()
|
|
358
366
|
elif t == "models":
|
|
359
367
|
# The models the control plane offers as CONFIDENTIAL today. Added to what this release knows, never subtracted
|
|
360
368
|
# (models.add_confidential), so a model added after this release still gets the floor + receipt — and no list from
|
|
@@ -964,7 +972,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
964
972
|
asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
|
|
965
973
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
966
974
|
model_url: str = "", remind_times: dict | None = None,
|
|
967
|
-
browse_probe: Callable[[], dict | None] | None = None
|
|
975
|
+
browse_probe: Callable[[], dict | None] | None = None,
|
|
976
|
+
browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> Connector:
|
|
968
977
|
"""Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
|
|
969
978
|
request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
|
|
970
979
|
client = client or local_client()
|
|
@@ -980,7 +989,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
980
989
|
max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
|
|
981
990
|
attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
|
|
982
991
|
notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times,
|
|
983
|
-
browse_probe=browse_probe)
|
|
992
|
+
browse_probe=browse_probe, browse_private=browse_private)
|
|
984
993
|
await s.start()
|
|
985
994
|
servers.append(s)
|
|
986
995
|
return Connector(servers, client, payers)
|
|
@@ -26,6 +26,8 @@ from ._js import b64u, truthy, unb64u_loose
|
|
|
26
26
|
|
|
27
27
|
LINK_PREFIX = "wbzlink1:"
|
|
28
28
|
_LINK_INFO = b"witbitz-device-link-v1"
|
|
29
|
+
_ACK_INFO = b"witbitz-device-link-ack-v1" # the report back to the giver (⇄ spaces/public/deviceLink.js ACK_INFO)
|
|
30
|
+
_WHY_MAX = 300
|
|
29
31
|
_BACKUP_SALT = b"witbitz-spaces-backup-v1"
|
|
30
32
|
|
|
31
33
|
|
|
@@ -52,11 +54,12 @@ def _import_pub(b64: Any) -> ec.EllipticCurvePublicKey:
|
|
|
52
54
|
raise ValueError("bad_point") from e
|
|
53
55
|
|
|
54
56
|
|
|
55
|
-
def _shared_key(priv: ec.EllipticCurvePrivateKey, pub: ec.EllipticCurvePublicKey, ref: Any) -> AESGCM:
|
|
56
|
-
"""Both sides derive the same AES-GCM key from the shared point, bound to the ref so a blob can't be replayed at another.
|
|
57
|
+
def _shared_key(priv: ec.EllipticCurvePrivateKey, pub: ec.EllipticCurvePublicKey, ref: Any, info: bytes = _LINK_INFO) -> AESGCM:
|
|
58
|
+
"""Both sides derive the same AES-GCM key from the shared point, bound to the ref so a blob can't be replayed at another.
|
|
59
|
+
`info` names its use: the reply, or the report back to the giver (_ACK_INFO)."""
|
|
57
60
|
bits = priv.exchange(ec.ECDH(), pub)
|
|
58
61
|
salt = _js.js_string(ref).encode("utf-8")
|
|
59
|
-
return AESGCM(HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=
|
|
62
|
+
return AESGCM(HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=info).derive(bits))
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
def new_link_challenge() -> LinkChallenge:
|
|
@@ -90,6 +93,18 @@ def seal_to_challenge(challenge: dict, payload: Any) -> dict:
|
|
|
90
93
|
return {"ref": challenge["ref"], "epk": _export_pub(mine.public_key()), "blob": b64u(iv + ct)}
|
|
91
94
|
|
|
92
95
|
|
|
96
|
+
def seal_link_ack(priv: ec.EllipticCurvePrivateKey, reply: dict, ok: bool, why: str = "") -> dict:
|
|
97
|
+
"""THE REPORT BACK TO THE GIVER (the owner, 2026-10-23: the phone said "Sent ✓" while this computer refused the other app's
|
|
98
|
+
code): {ok, why} sealed under a second key from the same exchange → {ref, ack} to POST to /api/link. Only the phone that
|
|
99
|
+
sealed the reply can open it (deviceLink.js openLinkAck)."""
|
|
100
|
+
key = _shared_key(priv, _import_pub(reply.get("epk")), reply.get("ref"), _ACK_INFO)
|
|
101
|
+
body: dict = {"v": 1, "ok": bool(ok)}
|
|
102
|
+
if why:
|
|
103
|
+
body["why"] = str(why)[:_WHY_MAX]
|
|
104
|
+
iv = os.urandom(12)
|
|
105
|
+
return {"ref": reply.get("ref"), "ack": b64u(iv + key.encrypt(iv, _js.stringify(body).encode("utf-8"), None))}
|
|
106
|
+
|
|
107
|
+
|
|
93
108
|
def open_link_reply(priv: ec.EllipticCurvePrivateKey, reply: dict) -> Any:
|
|
94
109
|
"""Open what the ref handed back. Raises on anything that isn't ours and intact."""
|
|
95
110
|
key = _shared_key(priv, _import_pub(reply.get("epk")), reply.get("ref"))
|
|
@@ -16,7 +16,7 @@ import os
|
|
|
16
16
|
import re
|
|
17
17
|
import sys
|
|
18
18
|
import time
|
|
19
|
-
from collections.abc import Callable
|
|
19
|
+
from collections.abc import Awaitable, Callable
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
from typing import Any
|
|
22
22
|
|
|
@@ -142,6 +142,22 @@ async def link_node(*, life_ms: float | None = None, poll_s: float = POLL_S, ori
|
|
|
142
142
|
await client.aclose()
|
|
143
143
|
|
|
144
144
|
|
|
145
|
+
def _reporter(priv, reply: dict, origin: str) -> Callable[[bool, str], Awaitable[None]]:
|
|
146
|
+
"""The report back to the giving phone (devicelink.seal_link_ack): best effort, on a client of its own — the link's is
|
|
147
|
+
closed by the time pairing ends. A phone that hears nothing says this computer has the keys."""
|
|
148
|
+
async def report(ok: bool, why: str = "") -> None:
|
|
149
|
+
client = None
|
|
150
|
+
try:
|
|
151
|
+
client = net.new_client()
|
|
152
|
+
await net.make_post_json(client)(f"{origin}/api/link", devicelink.seal_link_ack(priv, reply, ok, why))
|
|
153
|
+
except Exception: # noqa: BLE001 — the pairing's own outcome stands either way
|
|
154
|
+
pass
|
|
155
|
+
finally:
|
|
156
|
+
if client is not None:
|
|
157
|
+
await client.aclose()
|
|
158
|
+
return report
|
|
159
|
+
|
|
160
|
+
|
|
145
161
|
async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_path, out, err) -> dict | None:
|
|
146
162
|
ch = challenge or devicelink.new_link_challenge()
|
|
147
163
|
out(f"witbitz-code: pairing this computer with your account · polling {', '.join(origins)}\n")
|
|
@@ -181,10 +197,16 @@ async def _link(life_ms, poll_s, origins, get_json, post_json, challenge, svg_pa
|
|
|
181
197
|
if not pay:
|
|
182
198
|
err("witbitz-code: a reply arrived but did not open — start again on both devices.")
|
|
183
199
|
return None
|
|
200
|
+
reply = {"ref": ch.ref, "epk": r.get("epk")}
|
|
201
|
+
report = _reporter(ch.priv, reply, found)
|
|
184
202
|
account = account_from_payload(pay)
|
|
185
203
|
if not account:
|
|
186
204
|
err("witbitz-code: the reply carried no account key.")
|
|
205
|
+
await report(False, "the reply carried no account key.")
|
|
187
206
|
return None
|
|
207
|
+
# HOW IT WENT, for the phone that sent it (the owner, 2026-10-23: it said "Sent ✓" while this computer refused the
|
|
208
|
+
# code): pair.py calls it with the outcome. Never saved — upsert_pairing takes named fields only.
|
|
209
|
+
account["_report"] = report
|
|
188
210
|
# THE APP IT CAME FROM (the owner, 2026-10-22: a second account paired from the hosted app → "computers read refused
|
|
189
211
|
# (404: not_found)"): the relay that held the reply is that app's own address, and its rooms answer only their own app
|
|
190
212
|
# since 2026-10-18 — so this account's calls speak as it (pairings.json `origin`, net.call_for).
|
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
// owner :WB_BROWSE_OWNER_PORT (any free port by default) — the same pass-through for the PUBLISHER (the screencast and
|
|
17
17
|
// the owner's input), never closed by a take-over: the cloud computer's owner door (vm/cdpmux.go :9225)
|
|
18
18
|
// control POST /takeover {on, gone} on: every connection through the gate is dropped and new ones refused until it is off
|
|
19
|
+
// POST /private {on} PRIVATE BROWSING: the profile (cookies, logins, history) is wiped before Chromium starts,
|
|
20
|
+
// when it stops, and when this starts — so nothing outlives a session. Kept in a file beside
|
|
21
|
+
// the profile (<profile>.private), so it survives a restart; WB_BROWSE_PRIVATE=1 turns it on.
|
|
19
22
|
//
|
|
20
23
|
// WATCH AND TAKE OVER FROM THE APP (2026-10-19; the owner: "Both stages. Default."): with WB_BROWSE_KEYS (one base64 key per
|
|
21
24
|
// pairing — codeRelay.js deriveBrowseKey(secret)) and WB_BROWSE_PUBLISHER (the cloud computer's vm/browse-publisher.mjs, run
|
|
@@ -30,8 +33,8 @@
|
|
|
30
33
|
import { spawn } from 'node:child_process'
|
|
31
34
|
import { createServer, request as httpRequest } from 'node:http'
|
|
32
35
|
import { connect } from 'node:net'
|
|
33
|
-
import { existsSync, readdirSync, mkdirSync } from 'node:fs'
|
|
34
|
-
import { join } from 'node:path'
|
|
36
|
+
import { existsSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
37
|
+
import { join, resolve, parse } from 'node:path'
|
|
35
38
|
import { homedir, platform } from 'node:os'
|
|
36
39
|
|
|
37
40
|
const CTL_PORT = Number(process.env.WB_BROWSE_CTL_PORT || 9224)
|
|
@@ -75,12 +78,28 @@ export function wantHeaded (env = process.env) {
|
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
81
|
+
|
|
82
|
+
/** PRIVATE BROWSING's wipe: the dedicated profile goes, whole. Refuses a path that is a filesystem root or the home folder —
|
|
83
|
+
* WB_BROWSE_PROFILE is the person's to set, and a wipe must never be able to reach their own files. Chromium on Windows can
|
|
84
|
+
* hold a file for a moment after it exits, hence the retries. → true when nothing is left. */
|
|
85
|
+
export function wipeProfile (profile, { home = homedir(), rm = rmSync, exists = existsSync } = {}) {
|
|
86
|
+
const p = resolve(String(profile || ''))
|
|
87
|
+
if (!profile || p === parse(p).root || p === resolve(home)) return false
|
|
88
|
+
try { rm(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) } catch { /* reported below */ }
|
|
89
|
+
return !exists(p)
|
|
90
|
+
}
|
|
78
91
|
const getJson = (url) => fetch(url, { signal: AbortSignal.timeout(2000) }).then((r) => (r.ok ? r.json() : null)).catch(() => null)
|
|
79
92
|
|
|
80
93
|
/** The gate: a small object with start()/stop() around one Chromium, exported so a test can drive it on free ports. */
|
|
81
94
|
export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort = OWNER_PORT, profile = PROFILE, chrome = findChromium(), headed = wantHeaded(), startMs = START_MS, keys = KEYS, publisher = PUBLISHER, node = process.execPath, relay = process.env.WB_BROWSE_RELAY || '' } = {}) {
|
|
82
95
|
let child = null, realPort = 0, starting = null, lastErr = '', bound = gatePort // bound: the gate's ACTUAL port once listening (gatePort may be 0 = any)
|
|
83
96
|
let takeover = false
|
|
97
|
+
const privFile = profile + '.private'
|
|
98
|
+
let priv = existsSync(privFile)
|
|
99
|
+
if (/^(1|true|yes)$/i.test(String(process.env.WB_BROWSE_PRIVATE || ''))) priv = true
|
|
100
|
+
const keepPrivate = () => { try { if (priv) writeFileSync(privFile, '1\n'); else rmSync(privFile, { force: true }) } catch (e) { log('private browsing: could not keep the setting —', e.message) } }
|
|
101
|
+
keepPrivate()
|
|
102
|
+
const forget = (why) => { if (!priv) return; const ok = wipeProfile(profile); log(ok ? `private browsing: the profile is wiped (${why})` : `private browsing: THE PROFILE WAS NOT WIPED (${why})`) }
|
|
84
103
|
const gateSockets = new Set() // the agent's live CDP connections — what a take-over drops
|
|
85
104
|
const pubs = new Map() // key → { proc, stopped } — one publisher per pairing key
|
|
86
105
|
const running = () => !!(child && child.exitCode === null && realPort)
|
|
@@ -90,6 +109,7 @@ export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort
|
|
|
90
109
|
if (starting) return starting
|
|
91
110
|
starting = (async () => {
|
|
92
111
|
if (!chrome) throw new Error('no Chromium on this computer — install Google Chrome or Chromium, or set WB_BROWSE_CHROME to one')
|
|
112
|
+
forget('before it opens')
|
|
93
113
|
mkdirSync(profile, { recursive: true })
|
|
94
114
|
// --remote-debugging-port=0 lets Chromium pick a free port; it prints "DevToolsActivePort" into the profile dir and
|
|
95
115
|
// "DevTools listening on ws://…" on stderr — the stderr line is the quicker of the two to read.
|
|
@@ -103,7 +123,7 @@ export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort
|
|
|
103
123
|
child = p; realPort = 0; lastErr = ''
|
|
104
124
|
let err = ''
|
|
105
125
|
p.stderr.on('data', (d) => { err += String(d); const m = /DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//.exec(err); if (m && !realPort) realPort = Number(m[1]) })
|
|
106
|
-
p.on('exit', (code, sig) => { if (child === p) { child = null; realPort = 0; log(`chromium exited (${sig || code})`) } })
|
|
126
|
+
p.on('exit', (code, sig) => { if (child === p) { child = null; realPort = 0; log(`chromium exited (${sig || code})`); forget('it closed') } })
|
|
107
127
|
const t0 = Date.now()
|
|
108
128
|
while (!realPort && child === p && Date.now() - t0 < startMs) await sleep(100)
|
|
109
129
|
if (child !== p) throw new Error('Chromium exited before it was ready: ' + (err.trim().split('\n').pop() || '').slice(0, 160))
|
|
@@ -114,7 +134,7 @@ export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort
|
|
|
114
134
|
})().catch((e) => { lastErr = String(e && e.message ? e.message : e); throw e }).finally(() => { starting = null })
|
|
115
135
|
return starting
|
|
116
136
|
}
|
|
117
|
-
const report = () => ({ offered: true, running: running(), takeover, web: true, local: true, headed, ...(pubs.size ? { cast: true } : {}), ...(lastErr ? { lastError: lastErr } : {}) })
|
|
137
|
+
const report = () => ({ offered: true, running: running(), takeover, web: true, local: true, headed, private: priv, canPrivate: true, ...(pubs.size ? { cast: true } : {}), ...(lastErr ? { lastError: lastErr } : {}) })
|
|
118
138
|
const readBody = (req) => new Promise((resolve) => { let b = ''; req.setEncoding('utf8'); req.on('data', (d) => { if (b.length < 4096) b += d }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')) } catch { resolve({}) } }); req.on('error', () => resolve({})) })
|
|
119
139
|
|
|
120
140
|
const ctl = createServer(async (req, res) => {
|
|
@@ -124,6 +144,15 @@ export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort
|
|
|
124
144
|
return
|
|
125
145
|
}
|
|
126
146
|
if (req.url === '/status') return res.end(JSON.stringify(report()))
|
|
147
|
+
// PRIVATE BROWSING on/off (the app's switch on this computer's card, through the connector). Turned on while no browser
|
|
148
|
+
// runs, what the last session left is wiped now; while one runs, at its close.
|
|
149
|
+
if (req.method === 'POST' && req.url === '/private') {
|
|
150
|
+
const b = await readBody(req)
|
|
151
|
+
priv = !!b.on; keepPrivate()
|
|
152
|
+
log('private browsing', priv ? 'ON' : 'off')
|
|
153
|
+
if (priv && !running() && !starting) forget('turned on')
|
|
154
|
+
return res.end(JSON.stringify({ ok: true, ...report() }))
|
|
155
|
+
}
|
|
127
156
|
if (req.method === 'POST' && req.url === '/close') { try { child && child.kill() } catch { /* */ } takeover = false; return res.end(JSON.stringify({ ok: true })) }
|
|
128
157
|
// THE TAKE-OVER (the publisher asks, on the owner's sealed word — browse-publisher.mjs): on → the agent's connections go
|
|
129
158
|
// and stay gone until it is off; `gone` = the last viewer left, which ends it too (the cloud computer's rule).
|
|
@@ -193,6 +222,7 @@ export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort
|
|
|
193
222
|
|
|
194
223
|
return {
|
|
195
224
|
async start () {
|
|
225
|
+
forget('a new start') // a session that ended without a close (killed, a crash) leaves nothing behind either
|
|
196
226
|
await new Promise((res, rej) => { ctl.once('error', rej); ctl.listen(ctlPort, '127.0.0.1', res) })
|
|
197
227
|
await new Promise((res, rej) => { gate.once('error', rej); gate.listen(gatePort, '127.0.0.1', res) })
|
|
198
228
|
bound = gate.address().port
|
|
@@ -205,9 +235,11 @@ export function makeGate ({ ctlPort = CTL_PORT, gatePort = GATE_PORT, ownerPort
|
|
|
205
235
|
try { child && child.kill() } catch { /* */ }
|
|
206
236
|
for (let i = 0; i < 30 && child; i++) await sleep(100)
|
|
207
237
|
try { child && child.kill('SIGKILL') } catch { /* */ }
|
|
238
|
+
for (let i = 0; i < 20 && child; i++) await sleep(100)
|
|
239
|
+
forget('it stopped')
|
|
208
240
|
await new Promise((r) => ctl.close(() => r())); await new Promise((r) => gate.close(() => r())); await new Promise((r) => owner.close(() => r()))
|
|
209
241
|
},
|
|
210
|
-
open, report, running, get realPort () { return realPort }, get takeover () { return takeover },
|
|
242
|
+
open, report, running, get private () { return priv }, get realPort () { return realPort }, get takeover () { return takeover },
|
|
211
243
|
}
|
|
212
244
|
}
|
|
213
245
|
|
|
@@ -125,10 +125,22 @@ async def _link_by_qr(link_fn: LinkFn | None, out: Callable, err: Callable) -> d
|
|
|
125
125
|
idx = account.get("idx")
|
|
126
126
|
if not isinstance(idx, dict) or not truthy(idx.get("room")) or not truthy(idx.get("mk")):
|
|
127
127
|
err("witbitz-code: the scan carried no index-room pointer, so there is nowhere to publish — nothing changed.")
|
|
128
|
+
await _report(account, False, "your account's list of devices could not be found from this phone. Open the app once, then scan again.")
|
|
128
129
|
return None
|
|
129
130
|
return account
|
|
130
131
|
|
|
131
132
|
|
|
133
|
+
async def _report(account: dict | None, ok: bool, why: str = "") -> None:
|
|
134
|
+
"""Tell the phone that sent the keys how pairing went (link.py's reporter; the owner, 2026-10-23: it said "Sent ✓" while
|
|
135
|
+
this computer refused the code). Best effort — never changes the outcome."""
|
|
136
|
+
r = account.get("_report") if isinstance(account, dict) else None
|
|
137
|
+
if callable(r):
|
|
138
|
+
try:
|
|
139
|
+
await r(ok, why)
|
|
140
|
+
except Exception: # noqa: BLE001
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
132
144
|
async def rename(pairing: dict, name: str, *, call: net.ApiCall | None = None, out: Callable[[str], Any] = _out,
|
|
133
145
|
err: Callable[[str], Any] = _err) -> bool:
|
|
134
146
|
"""A pairing's label: in the account's computers (devices show it at their next sync), then in the pairings file. No scan."""
|
|
@@ -271,6 +283,8 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
271
283
|
if came != app.name():
|
|
272
284
|
err(f"witbitz-code: ✖ that code came from the {app.label(came)} — this Witbitz Code is the {app.label()}'s. "
|
|
273
285
|
f"Install the {app.label(came)}'s own from it: Code → Computers → Add a computer. Nothing changed.")
|
|
286
|
+
await _report(account, False, f"that computer's Witbitz Code is the {app.label()}'s. Install this app's own on it: "
|
|
287
|
+
"Code → Computers → Add a computer.")
|
|
274
288
|
return 1
|
|
275
289
|
port = None if args.opencode_url else pp.next_port(doc, account["idx"]["room"]) # another account: its own OpenCode
|
|
276
290
|
u = pp.upsert_pairing(doc, account=account.get("email") or "", idx=account["idx"], name=args.name,
|
|
@@ -285,14 +299,17 @@ async def _run(args: Args, call: Any, link_fn: LinkFn | None, out: Callable, err
|
|
|
285
299
|
computer_id=u["entry"]["computerId"], name=u["entry"]["name"])
|
|
286
300
|
except codekeys.CodeKeyRefusal as e:
|
|
287
301
|
err(f"witbitz-code: ✖ this computer got no coding keys: {e} — nothing changed.")
|
|
302
|
+
await _report(account, False, f"it got no coding keys: {e}")
|
|
288
303
|
return 1
|
|
289
304
|
ensure_local_password(out)
|
|
290
305
|
if not await _publish_entry(call, u["entry"], out, err):
|
|
291
306
|
await keys.revoke(entry) # nothing is left live for a pairing that did not happen
|
|
292
307
|
err("witbitz-code: nothing saved locally — scan again to retry.")
|
|
308
|
+
await _report(account, False, "it could not add itself to your account (offline?). Scan again to retry.")
|
|
293
309
|
return 1
|
|
294
310
|
codekeys.save(entry)
|
|
295
311
|
pp.write_pairings(u["doc"])
|
|
312
|
+
await _report(account, True) # the phone says "Added"
|
|
296
313
|
e = u["entry"]
|
|
297
314
|
out(f"witbitz-code: ✓ {'paired' if u['isNew'] else 'refreshed'} \"{e['name']}\" with {e['account'] or 'your account'} ({pp.pairings_path()})")
|
|
298
315
|
caps = entry.get("caps") or {}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The agent's browser on this computer (browse.py): the viewer key is the page's (codeRelay.js deriveBrowseKey), the files
|
|
2
|
+
install and go, and it is ON unless turned off (the owner, 2026-10-19: "Both stages. Default.")."""
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from witbitz_code import browse
|
|
10
|
+
from witbitz_code.relay import browse_key
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[3]
|
|
13
|
+
requires_node = pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
|
|
14
|
+
SECRET = "Y2bqk0a2N1pY9kQwF0x3c1u5mVb8zR2tL6eH4oJ7sD8"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@requires_node
|
|
18
|
+
def test_the_key_is_the_one_the_page_derives():
|
|
19
|
+
js = subprocess.run(["node", "-e", f"import('{ROOT}/spaces/public/codeRelay.js').then(async m => console.log(await m.deriveBrowseKey('{SECRET}')))"],
|
|
20
|
+
capture_output=True, text=True, check=True).stdout.strip()
|
|
21
|
+
assert browse_key(SECRET) == js
|
|
22
|
+
assert len(__import__("base64").b64decode(js)) == 32
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_install_and_remove(tmp_path):
|
|
26
|
+
paths = browse.install(tmp_path)
|
|
27
|
+
for name in ("browse-local.mjs", "browse-cdp.mjs", "browse-publisher.mjs", "witbitz-browser.js"):
|
|
28
|
+
assert paths[name].exists(), name
|
|
29
|
+
assert (tmp_path / browse.PLUGIN_REL).exists()
|
|
30
|
+
browse.remove(tmp_path)
|
|
31
|
+
assert not (tmp_path / browse.PLUGIN_REL).exists()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_on_by_default():
|
|
35
|
+
assert browse.disabled([], {}) is False
|
|
36
|
+
assert browse.disabled(["--no-browser"], {}) is True
|
|
37
|
+
assert browse.disabled([], {"WB_BROWSE_DISABLE": "1"}) is True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_hello_carries_the_browse_report():
|
|
41
|
+
src = (Path(browse.__file__).parent / "connector.py").read_text(encoding="utf-8")
|
|
42
|
+
assert '**({"browse": browse} if browse else {})' in src
|
|
43
|
+
assert "browse_probe=browse_probe" in src
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# PRIVATE BROWSING ON YOUR OWN COMPUTER (the owner, 2026-10-24: "why not?" → "Yes"): the twin of tools/code-browse-private.test.mjs.
|
|
47
|
+
def test_the_page_switches_private_browsing_with_a_nonce_checked_message():
|
|
48
|
+
import asyncio
|
|
49
|
+
|
|
50
|
+
from fakes import until
|
|
51
|
+
from test_connector_e2e import PyClient, Rig
|
|
52
|
+
|
|
53
|
+
from witbitz_code.connector import start_connector
|
|
54
|
+
|
|
55
|
+
async def go():
|
|
56
|
+
report = {"offered": True, "running": False, "local": True, "private": False, "canPrivate": True}
|
|
57
|
+
calls: list = []
|
|
58
|
+
|
|
59
|
+
async def set_private(on: bool) -> bool:
|
|
60
|
+
calls.append(on)
|
|
61
|
+
report["private"] = on
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
async with Rig() as rig:
|
|
65
|
+
c = await start_connector([rig.pairing()], flush_ms=40, log=lambda m: None, browse_probe=lambda: dict(report),
|
|
66
|
+
browse_private=set_private)
|
|
67
|
+
client = PyClient(rig.secret, rig.relay.url)
|
|
68
|
+
try:
|
|
69
|
+
await client.start()
|
|
70
|
+
assert client.hellos()[-1]["browse"]["canPrivate"] is True
|
|
71
|
+
await client.peer.send({"t": "browse", "k": "not-the-nonce", "private": True})
|
|
72
|
+
await client.peer.send({"t": "browse", "k": client.nonce, "private": "yes"})
|
|
73
|
+
await asyncio.sleep(0.3)
|
|
74
|
+
assert calls == [], "a recording or a malformed message switches nothing"
|
|
75
|
+
await client.peer.send({"t": "browse", "k": client.nonce, "private": True})
|
|
76
|
+
assert await until(lambda: client.hellos()[-1].get("browse", {}).get("private") is True, 5)
|
|
77
|
+
assert calls == [True]
|
|
78
|
+
finally:
|
|
79
|
+
await client.peer.aclose()
|
|
80
|
+
await c.aclose()
|
|
81
|
+
|
|
82
|
+
asyncio.run(go())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_a_tree_browser_takes_private_browsing_from_its_own_bundle_not_from_the_switch():
|
|
86
|
+
import asyncio
|
|
87
|
+
|
|
88
|
+
import httpx
|
|
89
|
+
|
|
90
|
+
from witbitz_code.browse import LocalBrowser
|
|
91
|
+
|
|
92
|
+
async def go():
|
|
93
|
+
b = LocalBrowser(None, 9, {}, httpx.AsyncClient(), tree="t1") # type: ignore[arg-type]
|
|
94
|
+
assert await b.set_private(True) is False
|
|
95
|
+
await b._http.aclose()
|
|
96
|
+
|
|
97
|
+
asyncio.run(go())
|
|
@@ -349,3 +349,38 @@ def test_the_python_and_node_connectors_read_each_others_keys_and_payers_files()
|
|
|
349
349
|
pub.verify(codekeys._unb64u(out["body"]["sig"]), codekeys.signed_message("status", e["keyId"], 1800000000).encode()) # raises if not
|
|
350
350
|
assert codekeys.for_computer("cmp_js")["trustedrouter"] == "sk-js" and SessionPayers().payer("ses_J") == "cmp_js"
|
|
351
351
|
assert codekeys.signed_body("status", codekeys.for_computer("cmp_js"))["sig"], "Python signs with a Node-made key"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@requires_node
|
|
355
|
+
def test_a_status_that_names_its_app_moves_the_keys_there_and_saves_them(monkeypatch):
|
|
356
|
+
"""The owner, 2026-10-23: each app its own computers and allowance. The status names this pairing's app and signs the
|
|
357
|
+
move ('move:<app>'); the server answers with a fresh pair under that app, which is opened and SAVED at once (the old one
|
|
358
|
+
is retired server-side, so unsaved keys would be lost)."""
|
|
359
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519 as ed
|
|
360
|
+
T2, U2, K2 = "t_" + "c" * 32, "u_" + "d" * 24, "k_" + "2" * 24
|
|
361
|
+
ident = codekeys.new_identity()
|
|
362
|
+
entry = {**ident, "account": "me@x", "computerId": "cmp_1", "name": "desk", "keyId": f"{U}.{K}", "tenantId": T, "userId": U,
|
|
363
|
+
"kid": K, "trustedrouter": "sk-tr-old", "app": "regular"}
|
|
364
|
+
codekeys.save(entry)
|
|
365
|
+
sealed = run_node("import { sealForEnclave } from '@REPO@/control-plane/src/lib/enclaveKeySeal.mjs';"
|
|
366
|
+
"OUT({ trustedrouter: await sealForEnclave('sk-tr-new', { tenantId: INPUT.t, userId: INPUT.u, kind: `code:${INPUT.k}:trustedrouter` }, INPUT.pub) })"
|
|
367
|
+
.replace("@REPO@", (__import__("conftest").REPO).as_uri()),
|
|
368
|
+
{"t": T2, "u": U2, "k": K2, "pub": codekeys.seal_pub(ident)})
|
|
369
|
+
seen = []
|
|
370
|
+
|
|
371
|
+
def handler(req):
|
|
372
|
+
b = json.loads(req.content)
|
|
373
|
+
seen.append(b)
|
|
374
|
+
pub = ed.Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(codekeys.sign_pub(ident) + "="))
|
|
375
|
+
pub.verify(base64.urlsafe_b64decode(b["moveSig"] + "=" * (-len(b["moveSig"]) % 4)), codekeys.signed_message("move:regular", b["keyId"], b["ts"]).encode())
|
|
376
|
+
return httpx.Response(200, json={"data": {"live": True, "moved": {"keyId": f"{U2}.{K2}", "tenantId": T2, "userId": U2, "kid": K2, "sealed": sealed, "caps": {"modelsUsd": 50}}}})
|
|
377
|
+
|
|
378
|
+
async def go():
|
|
379
|
+
async with client_for(handler) as c:
|
|
380
|
+
return await codekeys.status(c, entry)
|
|
381
|
+
st = run(go())
|
|
382
|
+
assert seen[0]["app"] == "regular"
|
|
383
|
+
assert st["live"] is True and st["movedTo"] == "regular"
|
|
384
|
+
saved = codekeys.for_computer("cmp_1")
|
|
385
|
+
assert (saved["keyId"], saved["tenantId"], saved["trustedrouter"]) == (f"{U2}.{K2}", T2, "sk-tr-new")
|
|
386
|
+
assert entry["trustedrouter"] == "sk-tr-new", "the running serve's entry follows too"
|
|
@@ -438,3 +438,35 @@ def test_a_store_moves_opencode_s_data_and_nothing_else(tmp_path, monkeypatch):
|
|
|
438
438
|
monkeypatch.delenv("XDG_DATA_HOME", raising=False)
|
|
439
439
|
asyncio.run(cli._start_opencode("opencode", 4096))
|
|
440
440
|
assert "XDG_DATA_HOME" not in seen["env"], "the default store: OpenCode's own"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def test_the_phone_that_sent_the_keys_hears_how_pairing_went(monkeypatch):
|
|
444
|
+
"""The owner, 2026-10-23: the phone said "Sent ✓" while this computer refused the code. Each outcome after the link is
|
|
445
|
+
reported back (link.py's reporter → devicelink.seal_link_ack): paired, the other app's code, no keys, not published."""
|
|
446
|
+
def scan(origin="https://witbitz-spaces.pages.dev", **extra):
|
|
447
|
+
heard = []
|
|
448
|
+
|
|
449
|
+
async def report(ok, why=""):
|
|
450
|
+
heard.append((ok, why))
|
|
451
|
+
|
|
452
|
+
async def link_fn():
|
|
453
|
+
return {"email": "me@x", "idx": IDX, "master": "M" * 43, "anchor": ANCHOR, "origin": origin, "_report": report, **extra}
|
|
454
|
+
return link_fn, heard
|
|
455
|
+
|
|
456
|
+
link_fn, heard = scan()
|
|
457
|
+
assert run(pair.main(["--name", "desk"], call=MockApi().call, link_fn=link_fn, keys=FakeKeys(), **Capture().kw())) == 0
|
|
458
|
+
assert heard == [(True, "")]
|
|
459
|
+
assert "_report" not in pp.read_pairings()["pairings"][0], "the reporter is never saved"
|
|
460
|
+
|
|
461
|
+
link_fn, heard = scan("https://spaces.witbitz.chat") # this install is the regular app's
|
|
462
|
+
assert run(pair.main([], call=MockApi().call, link_fn=link_fn, keys=FakeKeys(), **Capture().kw())) == 1
|
|
463
|
+
assert len(heard) == 1 and heard[0][0] is False and "regular app's" in heard[0][1] and "Add a computer" in heard[0][1]
|
|
464
|
+
|
|
465
|
+
link_fn, heard = scan()
|
|
466
|
+
keys = FakeKeys(refuse=("not_invited", "me@x is not on the list"))
|
|
467
|
+
assert run(pair.main([], call=MockApi().call, link_fn=link_fn, keys=keys, **Capture().kw())) == 1
|
|
468
|
+
assert heard == [(False, "it got no coding keys: me@x is not on the list")]
|
|
469
|
+
|
|
470
|
+
link_fn, heard = scan()
|
|
471
|
+
assert run(pair.main([], call=MockApi(read_status=503).call, link_fn=link_fn, keys=FakeKeys(), **Capture().kw())) == 1
|
|
472
|
+
assert heard and heard[0][0] is False and "Scan again" in heard[0][1]
|
|
@@ -105,3 +105,22 @@ def test_any_other_install_is_shown_the_command_never_run():
|
|
|
105
105
|
def test_the_restart_runs_the_same_manager_with_the_same_arguments(monkeypatch):
|
|
106
106
|
monkeypatch.setattr(su.sys, "argv", ["/x/bin/witbitz-code", "ui", "--hidden"])
|
|
107
107
|
assert su.restart_argv() == [su.sys.executable, "-m", "witbitz_code", "ui", "--hidden"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_a_vaulted_install_checks_and_updates_through_its_own_installer(monkeypatch):
|
|
111
|
+
# The page's Update ran https://app.witbitz.chat/code.ps1 — the REGULAR app's installer — on every install, so a vaulted
|
|
112
|
+
# app's Witbitz Code updated into the regular app's (the owner, 2026-10-23). Each install reads and runs its own app's.
|
|
113
|
+
monkeypatch.setenv("WITBITZ_CODE_APP", "vaulted")
|
|
114
|
+
seen = {"urls": [], "launch": []}
|
|
115
|
+
|
|
116
|
+
async def fetch(url):
|
|
117
|
+
seen["urls"].append(url)
|
|
118
|
+
return script("9.9.9")
|
|
119
|
+
u = su.Updates(fetch_text=fetch, install_info=lambda: {"kind": "windows-installer"}, current="1.0.0",
|
|
120
|
+
launch=lambda argv: seen["launch"].append(argv))
|
|
121
|
+
r = run(u.update())
|
|
122
|
+
assert r["ok"] and seen["urls"] and set(seen["urls"]) == {"https://app.witbitz.chat/code-vaulted.ps1"}
|
|
123
|
+
assert seen["launch"][0][-1] == "irm https://app.witbitz.chat/code-vaulted.ps1 | iex"
|
|
124
|
+
monkeypatch.setenv("WITBITZ_CODE_APP", "")
|
|
125
|
+
from witbitz_code import app
|
|
126
|
+
assert app.installer_url() == "https://app.witbitz.chat/code.ps1", "the regular app's, as every install before"
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""The agent's browser on this computer (browse.py): the viewer key is the page's (codeRelay.js deriveBrowseKey), the files
|
|
2
|
-
install and go, and it is ON unless turned off (the owner, 2026-10-19: "Both stages. Default.")."""
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
|
|
7
|
-
import pytest
|
|
8
|
-
|
|
9
|
-
from witbitz_code import browse
|
|
10
|
-
from witbitz_code.relay import browse_key
|
|
11
|
-
|
|
12
|
-
ROOT = Path(__file__).resolve().parents[3]
|
|
13
|
-
requires_node = pytest.mark.skipif(not shutil.which("node"), reason="node not installed")
|
|
14
|
-
SECRET = "Y2bqk0a2N1pY9kQwF0x3c1u5mVb8zR2tL6eH4oJ7sD8"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@requires_node
|
|
18
|
-
def test_the_key_is_the_one_the_page_derives():
|
|
19
|
-
js = subprocess.run(["node", "-e", f"import('{ROOT}/spaces/public/codeRelay.js').then(async m => console.log(await m.deriveBrowseKey('{SECRET}')))"],
|
|
20
|
-
capture_output=True, text=True, check=True).stdout.strip()
|
|
21
|
-
assert browse_key(SECRET) == js
|
|
22
|
-
assert len(__import__("base64").b64decode(js)) == 32
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def test_install_and_remove(tmp_path):
|
|
26
|
-
paths = browse.install(tmp_path)
|
|
27
|
-
for name in ("browse-local.mjs", "browse-cdp.mjs", "browse-publisher.mjs", "witbitz-browser.js"):
|
|
28
|
-
assert paths[name].exists(), name
|
|
29
|
-
assert (tmp_path / browse.PLUGIN_REL).exists()
|
|
30
|
-
browse.remove(tmp_path)
|
|
31
|
-
assert not (tmp_path / browse.PLUGIN_REL).exists()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def test_on_by_default():
|
|
35
|
-
assert browse.disabled([], {}) is False
|
|
36
|
-
assert browse.disabled(["--no-browser"], {}) is True
|
|
37
|
-
assert browse.disabled([], {"WB_BROWSE_DISABLE": "1"}) is True
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_hello_carries_the_browse_report():
|
|
41
|
-
src = (Path(browse.__file__).parent / "connector.py").read_text(encoding="utf-8")
|
|
42
|
-
assert '**({"browse": browse} if browse else {})' in src
|
|
43
|
-
assert "browse_probe=browse_probe" in src
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js
RENAMED
|
File without changes
|
|
File without changes
|
{witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.11.0 → witbitz_code-1.13.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|