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