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.
Files changed (122) hide show
  1. {witbitz_code-1.12.0/src/witbitz_code.egg-info → witbitz_code-1.14.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/browse.py +12 -0
  5. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/cli.py +5 -2
  6. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/connector.py +48 -5
  7. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/devicelink.py +18 -3
  8. witbitz_code-1.14.0/src/witbitz_code/files.py +110 -0
  9. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/link.py +23 -1
  10. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +37 -5
  11. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/outputs.py +8 -4
  12. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/pair.py +17 -0
  13. {witbitz_code-1.12.0 → witbitz_code-1.14.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  14. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  15. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_auto.py +1 -1
  16. witbitz_code-1.14.0/tests/test_browse.py +97 -0
  17. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_connector_e2e.py +27 -1
  18. witbitz_code-1.14.0/tests/test_files.py +71 -0
  19. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_pair_flows.py +32 -0
  20. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_selfupdate.py +19 -0
  21. witbitz_code-1.12.0/tests/test_browse.py +0 -43
  22. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/LICENSE +0 -0
  23. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/README.md +0 -0
  24. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/setup.cfg +0 -0
  25. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/__main__.py +0 -0
  26. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/_js.py +0 -0
  27. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/account.py +0 -0
  28. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/app.py +0 -0
  29. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/asks.py +0 -0
  30. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/attachments.py +0 -0
  31. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/auto.py +0 -0
  32. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/auto_runner.py +0 -0
  33. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/browsetree.py +0 -0
  34. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/codekeys.py +0 -0
  35. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/codetools.py +0 -0
  36. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/compress.py +0 -0
  37. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/computers.py +0 -0
  38. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential.py +0 -0
  39. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential_models.json +0 -0
  40. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/dirs.py +0 -0
  41. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/folders.py +0 -0
  42. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/gateway.py +0 -0
  43. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  44. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  45. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/images.py +0 -0
  46. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/keys.py +0 -0
  47. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/mkdir.py +0 -0
  48. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/models.py +0 -0
  49. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/net.py +0 -0
  50. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/offered_models.json +0 -0
  51. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  52. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  53. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  54. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  55. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  56. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  57. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  58. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  59. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/pairings.py +0 -0
  60. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/payers.py +0 -0
  61. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/plugins.py +0 -0
  62. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/policy.py +0 -0
  63. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ports.py +0 -0
  64. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/qr.py +0 -0
  65. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/receipt.py +0 -0
  66. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/relay.py +0 -0
  67. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/remind.py +0 -0
  68. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/seccheck.py +0 -0
  69. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/seen.py +0 -0
  70. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/selfupdate.py +0 -0
  71. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/tinfoil_read.py +0 -0
  72. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_catalog.json +0 -0
  73. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_probe.py +0 -0
  74. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/toolsetup.py +0 -0
  75. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui.py +0 -0
  76. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.css +0 -0
  77. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.js +0 -0
  78. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/index.html +0 -0
  79. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/uitools.py +0 -0
  80. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/uninstall.py +0 -0
  81. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/winagent.py +0 -0
  82. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/winbg.py +0 -0
  83. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/winconsole.py +0 -0
  84. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/wizard.py +0 -0
  85. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code/wslstart.py +0 -0
  86. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  87. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  88. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  89. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  90. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_asks.py +0 -0
  91. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_attachments.py +0 -0
  92. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_browsetree.py +0 -0
  93. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_cli.py +0 -0
  94. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_codekeys.py +0 -0
  95. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_codetools.py +0 -0
  96. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_confidential.py +0 -0
  97. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_devicelink.py +0 -0
  98. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_dirs.py +0 -0
  99. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_files_and_compress.py +0 -0
  100. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_folders.py +0 -0
  101. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_images.py +0 -0
  102. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_outputs.py +0 -0
  103. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_plugins.py +0 -0
  104. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_policy.py +0 -0
  105. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ports.py +0 -0
  106. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ports_win.py +0 -0
  107. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_registry.py +0 -0
  108. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_relay_codec.py +0 -0
  109. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_remind.py +0 -0
  110. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_seccheck.py +0 -0
  111. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_setup.py +0 -0
  112. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_tools_probe.py +0 -0
  113. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_toolsetup.py +0 -0
  114. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ui.py +0 -0
  115. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_ui_page.py +0 -0
  116. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_uitools.py +0 -0
  117. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_uninstall.py +0 -0
  118. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_winagent.py +0 -0
  119. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_winbg.py +0 -0
  120. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_winconsole.py +0 -0
  121. {witbitz_code-1.12.0 → witbitz_code-1.14.0}/tests/test_windows_files.py +0 -0
  122. {witbitz_code-1.12.0 → witbitz_code-1.14.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.14.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.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"
@@ -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.14.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
@@ -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) -> 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) -> Connector:
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=_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"))
@@ -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) -> tuple[int, str]:
75
- """One produced file (or its facts, with `stat`) for the page: (status, JSON body) in the connector's reply shape."""
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 {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.12.0
3
+ Version: 1.14.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
@@ -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)