witbitz-code 1.12.0__tar.gz → 1.14.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.12.0/src/witbitz_code.egg-info → witbitz_code-1.14.0}/PKG-INFO +1 -1
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/pyproject.toml +1 -1
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/browse.py +12 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/cli.py +5 -2
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/connector.py +48 -5
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/devicelink.py +18 -3
- witbitz_code-1.14.0/src/witbitz_code/files.py +110 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/link.py +23 -1
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +37 -5
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/outputs.py +8 -4
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/pair.py +17 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_auto.py +1 -1
- witbitz_code-1.14.0/tests/test_browse.py +97 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_connector_e2e.py +27 -1
- witbitz_code-1.14.0/tests/test_files.py +71 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_pair_flows.py +32 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_selfupdate.py +19 -0
- witbitz_code-1.12.0/tests/test_browse.py +0 -43
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/LICENSE +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/README.md +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/setup.cfg +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/app.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.js +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_images.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ui.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ui_page.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.12.0 → witbitz_code-1.14.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.14.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:
|
|
@@ -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
|
|
@@ -39,6 +39,7 @@ from .attachments import (ATTACHMENT_ROUTE, MAX_FILE_BYTES, attachment_rule, def
|
|
|
39
39
|
from .auto_runner import AutoRunner, detail_of
|
|
40
40
|
from .remind import ReminderClock, seal as seal_remind, valid as valid_remind # "waiting for your approval" (docs/approval-reminders.md)
|
|
41
41
|
from .outputs import OUTPUT_ROUTE, serve_output
|
|
42
|
+
from .files import FILES_DOWNLOAD_ROUTE, FILES_UPLOAD_ROUTE, download_file, upload_file
|
|
42
43
|
from .plugins import notes_plugin_path as default_notes_plugin, notes_root as default_notes_root, notes_rules
|
|
43
44
|
from .payers import SessionPayers
|
|
44
45
|
from .seen import SEEN_ROUTE, merge_seen, norm_seen
|
|
@@ -162,8 +163,10 @@ class PairingServer:
|
|
|
162
163
|
asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
|
|
163
164
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
164
165
|
model_url: str = "", remind_times: dict | None = None,
|
|
165
|
-
browse_probe: Callable[[], dict | None] | None = None
|
|
166
|
+
browse_probe: Callable[[], dict | None] | None = None,
|
|
167
|
+
browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> None:
|
|
166
168
|
self.pairing = pairing
|
|
169
|
+
self._browse_private = browse_private # private browsing on/off (browse.py LocalBrowser.set_private)
|
|
167
170
|
# THE AGENT'S BROWSER ON THIS COMPUTER (browse.py): its /status rides the hello; a change is announced within 2 s
|
|
168
171
|
self._browse_probe = browse_probe
|
|
169
172
|
self._browse_said = "" # the last browse report the page was told, as JSON
|
|
@@ -316,7 +319,7 @@ class PairingServer:
|
|
|
316
319
|
self._browse_said = json.dumps(browse, sort_keys=True)
|
|
317
320
|
# caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
|
|
318
321
|
return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
|
|
319
|
-
"k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], "auto": self.auto.sessions(),
|
|
322
|
+
"k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind", "files"], "auto": self.auto.sessions(),
|
|
320
323
|
**({"browse": browse} if browse else {})})
|
|
321
324
|
|
|
322
325
|
def _auth(self) -> dict:
|
|
@@ -355,6 +358,12 @@ class PairingServer:
|
|
|
355
358
|
# Which suggested tools this computer has (spaces/public/codeTools.js) — a PATH lookup, nonce-checked like a request.
|
|
356
359
|
if current:
|
|
357
360
|
await self.peer.send({"t": "tools", **probe_tools()})
|
|
361
|
+
elif t == "browse":
|
|
362
|
+
# PRIVATE BROWSING for the agent's browser on this computer (browse-local.mjs wipes its profile at every open and
|
|
363
|
+
# close). Nonce-checked like a request; only a browser that says `canPrivate` is offered the switch by the page.
|
|
364
|
+
if current and self._browse_private and isinstance(m.get("private"), bool):
|
|
365
|
+
await self._browse_private(m["private"])
|
|
366
|
+
self.hello()
|
|
358
367
|
elif t == "models":
|
|
359
368
|
# The models the control plane offers as CONFIDENTIAL today. Added to what this release knows, never subtracted
|
|
360
369
|
# (models.add_confidential), so a model added after this release still gets the floor + receipt — and no list from
|
|
@@ -409,6 +418,39 @@ class PairingServer:
|
|
|
409
418
|
"stat": stat_only, "st": st})
|
|
410
419
|
await reply(st, b)
|
|
411
420
|
return
|
|
421
|
+
# The Files sheet (files.py ⇄ tools/code-files.mjs): Download any file of the session's folder — nothing secret-looking —
|
|
422
|
+
# and Upload into it. Answered HERE; the folder is the session's, as OpenCode reports it; logged as a digest of the path.
|
|
423
|
+
if (m.get("m") == "GET" and bare == FILES_DOWNLOAD_ROUTE) or (m.get("m") == "POST" and bare == FILES_UPLOAD_ROUTE):
|
|
424
|
+
q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
|
|
425
|
+
sid = q.get("session", "")
|
|
426
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", sid):
|
|
427
|
+
await reply(400, {"error": "not a session id"})
|
|
428
|
+
return
|
|
429
|
+
dir_query = "directory=" + urllib.parse.quote(q["directory"], safe="!'()*") if q.get("directory") else "" # encodeURIComponent
|
|
430
|
+
session = await self._session_for(sid, dir_query)
|
|
431
|
+
if not session or not isinstance(session.get("directory"), str):
|
|
432
|
+
await reply(404, {"error": "no such session on this computer"})
|
|
433
|
+
return
|
|
434
|
+
now = int(time.time() * 1000)
|
|
435
|
+
if m.get("m") == "GET":
|
|
436
|
+
st, b = download_file(directory=session["directory"], path=q.get("path"))
|
|
437
|
+
self._log_output({"at": now, "session": sid, "digest": hashlib.sha256(q.get("path", "").encode()).hexdigest(),
|
|
438
|
+
"download": True, "st": st})
|
|
439
|
+
await reply(st, b)
|
|
440
|
+
return
|
|
441
|
+
try:
|
|
442
|
+
f = json.loads(m.get("b") or "null")
|
|
443
|
+
except ValueError:
|
|
444
|
+
f = None
|
|
445
|
+
if not isinstance(f, dict):
|
|
446
|
+
await reply(400, {"error": "no file"})
|
|
447
|
+
return
|
|
448
|
+
st, b = upload_file(directory=session["directory"], dir=f.get("dir"), name=f.get("name"), b64=f.get("b64"),
|
|
449
|
+
replace=f.get("replace") is True)
|
|
450
|
+
self._log_output({"at": now, "session": sid, "digest": hashlib.sha256(f"{f.get('dir')}/{f.get('name')}".encode()).hexdigest(),
|
|
451
|
+
"upload": True, "st": st})
|
|
452
|
+
await reply(st, b)
|
|
453
|
+
return
|
|
412
454
|
# What the person has seen, shared by their devices (seen.py): answered HERE and kept on this computer.
|
|
413
455
|
if bare == SEEN_ROUTE and m.get("m") in ("GET", "POST"):
|
|
414
456
|
if m.get("m") == "GET":
|
|
@@ -964,7 +1006,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
964
1006
|
asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
|
|
965
1007
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
966
1008
|
model_url: str = "", remind_times: dict | None = None,
|
|
967
|
-
browse_probe: Callable[[], dict | None] | None = None
|
|
1009
|
+
browse_probe: Callable[[], dict | None] | None = None,
|
|
1010
|
+
browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> Connector:
|
|
968
1011
|
"""Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
|
|
969
1012
|
request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
|
|
970
1013
|
client = client or local_client()
|
|
@@ -980,7 +1023,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
980
1023
|
max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
|
|
981
1024
|
attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
|
|
982
1025
|
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)
|
|
1026
|
+
browse_probe=browse_probe, browse_private=browse_private)
|
|
984
1027
|
await s.start()
|
|
985
1028
|
servers.append(s)
|
|
986
1029
|
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"))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The Files sheet in a Code session — tools/code-files.mjs, in Python.
|
|
2
|
+
|
|
3
|
+
DOWNLOAD (GET /witbitz/download?session=&path=): serve_output with `any_kind` — in the session's folder, no link out, nothing
|
|
4
|
+
secret-looking, 20 MB; any regular file. UPLOAD (POST /witbitz/upload?session=, body {dir, name, b64, replace}): one file
|
|
5
|
+
name (is_upload_name), into a folder whose REAL path is the session's or under it; a name already there is refused (409)
|
|
6
|
+
unless `replace`, and a folder or link under that name is never replaced. Written to a hidden temporary beside it and renamed
|
|
7
|
+
into place. As root, the file takes the uid/gid of the folder it lands in (a cloud computer's connector writing for the agent);
|
|
8
|
+
anyone else is the person, and nothing changes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import binascii
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import stat as stat_mod
|
|
20
|
+
import unicodedata
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .attachments import MAX_SERVE_BYTES
|
|
24
|
+
from .auto import posix_normalize
|
|
25
|
+
from .outputs import serve_output, valid_output_path
|
|
26
|
+
|
|
27
|
+
FILES_DOWNLOAD_ROUTE = "/witbitz/download"
|
|
28
|
+
FILES_UPLOAD_ROUTE = "/witbitz/upload"
|
|
29
|
+
MAX_FILE_TRANSFER = MAX_SERVE_BYTES # 20 MB: base64 in one relay message stays under its 32 MiB reassembly cap
|
|
30
|
+
_B64 = re.compile(r"[A-Za-z0-9+/]*={0,2}\Z")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_upload_name(n: Any) -> bool:
|
|
34
|
+
"""A FILE's name (⇄ code-files.mjs isUploadName): no / or \\ or NUL, not hidden, at most 200 characters, nothing
|
|
35
|
+
invisible (a control or format character — a direction mark, a zero-width space), no surrounding spaces."""
|
|
36
|
+
s = unicodedata.normalize("NFC", n) if isinstance(n, str) else ""
|
|
37
|
+
return (bool(s) and len(s) <= 200 and not s.startswith(".") and not re.search(r"[/\\\0]", s)
|
|
38
|
+
and not any(unicodedata.category(c) in ("Cc", "Cf") for c in s) and s.strip() == s)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _answer(st: int, o: dict) -> tuple[int, str]:
|
|
42
|
+
return st, json.dumps(o, ensure_ascii=False, separators=(",", ":"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _as_root() -> bool:
|
|
46
|
+
return hasattr(os, "geteuid") and os.geteuid() == 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def download_file(*, directory: Any, path: Any, max_bytes: int = MAX_FILE_TRANSFER) -> tuple[int, str]:
|
|
50
|
+
return serve_output(directory=directory, path=path, max_bytes=max_bytes, any_kind=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def upload_file(*, directory: Any, dir: Any, name: Any, b64: Any, replace: bool = False,
|
|
54
|
+
max_bytes: int = MAX_FILE_TRANSFER, as_root: bool | None = None) -> tuple[int, str]:
|
|
55
|
+
as_root = _as_root() if as_root is None else as_root
|
|
56
|
+
if isinstance(name, str):
|
|
57
|
+
name = unicodedata.normalize("NFC", name)
|
|
58
|
+
if not is_upload_name(name):
|
|
59
|
+
return _answer(400, {"error": "a file name: no / or \\, not hidden, nothing invisible"})
|
|
60
|
+
if not isinstance(directory, str) or not directory.startswith("/") or not valid_output_path(dir):
|
|
61
|
+
return _answer(400, {"error": "not a folder in this session"})
|
|
62
|
+
root = re.sub(r"/+\Z", "", posix_normalize(directory))
|
|
63
|
+
where = re.sub(r"/+\Z", "", posix_normalize(dir)) or "/"
|
|
64
|
+
if not root or not (where == root or where.startswith(root + "/")):
|
|
65
|
+
return _answer(403, {"error": "that folder is outside the session's folder"})
|
|
66
|
+
if not isinstance(b64, str) or not _B64.match(b64):
|
|
67
|
+
return _answer(400, {"error": "not a file's bytes"})
|
|
68
|
+
try:
|
|
69
|
+
data = base64.b64decode(b64, validate=True)
|
|
70
|
+
except (binascii.Error, ValueError):
|
|
71
|
+
return _answer(400, {"error": "not a file's bytes"})
|
|
72
|
+
if len(data) > max_bytes:
|
|
73
|
+
return _answer(413, {"error": f"the file is over {round(max_bytes / 1048576)} MB — too large to send to the computer"})
|
|
74
|
+
try:
|
|
75
|
+
real_root = os.path.realpath(root, strict=True)
|
|
76
|
+
except OSError:
|
|
77
|
+
return _answer(404, {"error": "the session's folder is not on this computer"})
|
|
78
|
+
try:
|
|
79
|
+
real_where = os.path.realpath(where, strict=True)
|
|
80
|
+
except OSError:
|
|
81
|
+
return _answer(404, {"error": "that folder is not on the computer (any more)"})
|
|
82
|
+
if not (real_where == real_root or real_where.startswith(real_root + "/")):
|
|
83
|
+
return _answer(403, {"error": "that folder leads outside the session's folder"})
|
|
84
|
+
folder = os.stat(real_where)
|
|
85
|
+
if not stat_mod.S_ISDIR(folder.st_mode):
|
|
86
|
+
return _answer(400, {"error": "not a folder"})
|
|
87
|
+
target = os.path.join(real_where, name)
|
|
88
|
+
try:
|
|
89
|
+
there = os.lstat(target)
|
|
90
|
+
except OSError:
|
|
91
|
+
there = None
|
|
92
|
+
if there is not None and not stat_mod.S_ISREG(there.st_mode):
|
|
93
|
+
return _answer(409, {"error": "a folder or link of that name is there", "exists": True, "replaceable": False})
|
|
94
|
+
if there is not None and not replace:
|
|
95
|
+
return _answer(409, {"error": "a file of that name is already there", "exists": True, "replaceable": True})
|
|
96
|
+
tmp = os.path.join(real_where, f".{name}.upload-{secrets.token_hex(6)}")
|
|
97
|
+
try:
|
|
98
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
|
99
|
+
with os.fdopen(fd, "wb") as f:
|
|
100
|
+
f.write(data)
|
|
101
|
+
if as_root:
|
|
102
|
+
os.chown(tmp, folder.st_uid, folder.st_gid)
|
|
103
|
+
os.replace(tmp, target)
|
|
104
|
+
except OSError as e:
|
|
105
|
+
try:
|
|
106
|
+
os.unlink(tmp)
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return _answer(500, {"error": f"could not save it here ({str(e)[:120]})"})
|
|
110
|
+
return _answer(200, {"ok": True, "name": name, "path": target, "size": len(data), "replaced": there is not None})
|
|
@@ -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
|
|
|
@@ -71,8 +71,12 @@ def _answer(st: int, o: dict) -> tuple[int, str]:
|
|
|
71
71
|
return st, _js.stringify(o)
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: int = MAX_SERVE_BYTES
|
|
75
|
-
|
|
74
|
+
def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: int = MAX_SERVE_BYTES,
|
|
75
|
+
any_kind: bool = False) -> tuple[int, str]:
|
|
76
|
+
"""One produced file (or its facts, with `stat`) for the page: (status, JSON body) in the connector's reply shape.
|
|
77
|
+
|
|
78
|
+
`any_kind`: the Files sheet's Download (files.py) — any regular file, not only a kind the page previews; every other
|
|
79
|
+
check stays (⇄ tools/code-outputs.mjs)."""
|
|
76
80
|
if not isinstance(directory, str) or not directory.startswith("/") or not valid_output_path(path):
|
|
77
81
|
return _answer(400, {"error": "not a file in this session"})
|
|
78
82
|
d = re.sub(r"/+\Z", "", posix_normalize(directory))
|
|
@@ -81,7 +85,7 @@ def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: in
|
|
|
81
85
|
return _answer(403, {"error": "that file is outside the session's folder"})
|
|
82
86
|
if _sensitive_path(a[len(d):]):
|
|
83
87
|
return _answer(403, {"error": "not shown: the name looks like a secret"})
|
|
84
|
-
if not output_kind(a):
|
|
88
|
+
if not any_kind and not output_kind(a):
|
|
85
89
|
return _answer(400, {"error": "not a file the page shows"})
|
|
86
90
|
try:
|
|
87
91
|
real_dir = os.path.realpath(d, strict=True)
|
|
@@ -95,7 +99,7 @@ def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: in
|
|
|
95
99
|
return _answer(403, {"error": "that file leads outside the session's folder"})
|
|
96
100
|
if _sensitive_path(real[len(real_dir):]):
|
|
97
101
|
return _answer(403, {"error": "not shown: the file looks like a secret"})
|
|
98
|
-
kind = output_kind(real)
|
|
102
|
+
kind = output_kind(real) or ("file" if any_kind else None)
|
|
99
103
|
if not kind:
|
|
100
104
|
return _answer(400, {"error": "not a file the page shows"})
|
|
101
105
|
try:
|
|
@@ -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 {}
|
|
@@ -22,6 +22,7 @@ src/witbitz_code/confidential_models.json
|
|
|
22
22
|
src/witbitz_code/connector.py
|
|
23
23
|
src/witbitz_code/devicelink.py
|
|
24
24
|
src/witbitz_code/dirs.py
|
|
25
|
+
src/witbitz_code/files.py
|
|
25
26
|
src/witbitz_code/folders.py
|
|
26
27
|
src/witbitz_code/gateway.py
|
|
27
28
|
src/witbitz_code/images.py
|
|
@@ -89,6 +90,7 @@ tests/test_confidential.py
|
|
|
89
90
|
tests/test_connector_e2e.py
|
|
90
91
|
tests/test_devicelink.py
|
|
91
92
|
tests/test_dirs.py
|
|
93
|
+
tests/test_files.py
|
|
92
94
|
tests/test_files_and_compress.py
|
|
93
95
|
tests/test_folders.py
|
|
94
96
|
tests/test_images.py
|
|
@@ -599,7 +599,7 @@ def test_wiring_the_page_switches_auto_and_the_verdict_comes_back(make, tmp_path
|
|
|
599
599
|
client = PyClient(rig.secret, rig.relay.url)
|
|
600
600
|
try:
|
|
601
601
|
await client.start()
|
|
602
|
-
assert client.hellos()[-1]["caps"] == ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"]
|
|
602
|
+
assert client.hellos()[-1]["caps"] == ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind", "files"]
|
|
603
603
|
# a stale nonce cannot switch it: a recording cannot turn Auto on
|
|
604
604
|
await client.peer.send({"t": "auto", "k": "not-the-nonce", "sid": "ses_main", "dir": DIR, "on": True})
|
|
605
605
|
await asyncio.sleep(0.3)
|