witbitz-code 1.13.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 (121) hide show
  1. {witbitz_code-1.13.0/src/witbitz_code.egg-info → witbitz_code-1.14.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/connector.py +35 -1
  5. witbitz_code-1.14.0/src/witbitz_code/files.py +110 -0
  6. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/outputs.py +8 -4
  7. {witbitz_code-1.13.0 → witbitz_code-1.14.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  8. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  9. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_auto.py +1 -1
  10. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_connector_e2e.py +27 -1
  11. witbitz_code-1.14.0/tests/test_files.py +71 -0
  12. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/LICENSE +0 -0
  13. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/README.md +0 -0
  14. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/setup.cfg +0 -0
  15. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/__main__.py +0 -0
  16. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/_js.py +0 -0
  17. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/account.py +0 -0
  18. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/app.py +0 -0
  19. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/asks.py +0 -0
  20. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/attachments.py +0 -0
  21. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/auto.py +0 -0
  22. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/auto_runner.py +0 -0
  23. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/browse.py +0 -0
  24. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/browsetree.py +0 -0
  25. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/cli.py +0 -0
  26. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/codekeys.py +0 -0
  27. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/codetools.py +0 -0
  28. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/compress.py +0 -0
  29. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/computers.py +0 -0
  30. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential.py +0 -0
  31. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential_models.json +0 -0
  32. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/devicelink.py +0 -0
  33. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/dirs.py +0 -0
  34. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/folders.py +0 -0
  35. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/gateway.py +0 -0
  36. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  37. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  38. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/images.py +0 -0
  39. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/keys.py +0 -0
  40. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/link.py +0 -0
  41. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/mkdir.py +0 -0
  42. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/models.py +0 -0
  43. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/net.py +0 -0
  44. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/offered_models.json +0 -0
  45. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  46. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  47. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  48. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
  49. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
  50. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  51. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  52. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  53. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  54. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/pair.py +0 -0
  55. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/pairings.py +0 -0
  56. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/payers.py +0 -0
  57. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/plugins.py +0 -0
  58. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/policy.py +0 -0
  59. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ports.py +0 -0
  60. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/qr.py +0 -0
  61. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/receipt.py +0 -0
  62. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/relay.py +0 -0
  63. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/remind.py +0 -0
  64. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/seccheck.py +0 -0
  65. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/seen.py +0 -0
  66. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/selfupdate.py +0 -0
  67. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/tinfoil_read.py +0 -0
  68. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_catalog.json +0 -0
  69. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_probe.py +0 -0
  70. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/toolsetup.py +0 -0
  71. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui.py +0 -0
  72. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.css +0 -0
  73. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.js +0 -0
  74. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/index.html +0 -0
  75. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/uitools.py +0 -0
  76. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/uninstall.py +0 -0
  77. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/winagent.py +0 -0
  78. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/winbg.py +0 -0
  79. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/winconsole.py +0 -0
  80. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/wizard.py +0 -0
  81. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/wslstart.py +0 -0
  82. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  83. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  84. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  85. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  86. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_asks.py +0 -0
  87. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_attachments.py +0 -0
  88. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_browse.py +0 -0
  89. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_browsetree.py +0 -0
  90. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_cli.py +0 -0
  91. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_codekeys.py +0 -0
  92. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_codetools.py +0 -0
  93. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_confidential.py +0 -0
  94. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_devicelink.py +0 -0
  95. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_dirs.py +0 -0
  96. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_files_and_compress.py +0 -0
  97. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_folders.py +0 -0
  98. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_images.py +0 -0
  99. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_outputs.py +0 -0
  100. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_pair_flows.py +0 -0
  101. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_plugins.py +0 -0
  102. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_policy.py +0 -0
  103. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ports.py +0 -0
  104. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ports_win.py +0 -0
  105. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_registry.py +0 -0
  106. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_relay_codec.py +0 -0
  107. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_remind.py +0 -0
  108. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_seccheck.py +0 -0
  109. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_selfupdate.py +0 -0
  110. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_setup.py +0 -0
  111. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_tools_probe.py +0 -0
  112. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_toolsetup.py +0 -0
  113. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ui.py +0 -0
  114. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ui_page.py +0 -0
  115. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_uitools.py +0 -0
  116. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_uninstall.py +0 -0
  117. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_winagent.py +0 -0
  118. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_winbg.py +0 -0
  119. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_winconsole.py +0 -0
  120. {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_windows_files.py +0 -0
  121. {witbitz_code-1.13.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.13.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.13.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.13.0"
7
+ __version__ = "1.14.0"
@@ -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
@@ -318,7 +319,7 @@ class PairingServer:
318
319
  self._browse_said = json.dumps(browse, sort_keys=True)
319
320
  # caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
320
321
  return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
321
- "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(),
322
323
  **({"browse": browse} if browse else {})})
323
324
 
324
325
  def _auth(self) -> dict:
@@ -417,6 +418,39 @@ class PairingServer:
417
418
  "stat": stat_only, "st": st})
418
419
  await reply(st, b)
419
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
420
454
  # What the person has seen, shared by their devices (seen.py): answered HERE and kept on this computer.
421
455
  if bare == SEEN_ROUTE and m.get("m") in ("GET", "POST"):
422
456
  if m.get("m") == "GET":
@@ -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})
@@ -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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.13.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)
@@ -174,7 +174,7 @@ async def conformance(rig: Rig, connector) -> None:
174
174
  await client.start()
175
175
  hello = client.hellos()[-1]
176
176
  assert set(hello) == {"t", "ver", "name", "computerId", "k", "ts", "caps", "auto"} and hello["ver"] == "1"
177
- assert (hello["caps"], hello["auto"]) == (["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], []), "both connectors do Auto mode (test_auto.py), save attachments, serve produced files, share what was seen, keep what is waiting (test_asks.py) and allow a folder (test_folders.py)"
177
+ assert (hello["caps"], hello["auto"]) == (["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind", "files"], []), "both connectors do Auto mode (test_auto.py), save attachments, serve produced files, share what was seen, keep what is waiting (test_asks.py), allow a folder (test_folders.py) and upload/download in the Files sheet (test_files.py)"
178
178
  assert (hello["name"], hello["computerId"]) == ("test-box", "cmp_test") and isinstance(hello["ts"], int)
179
179
  assert re.fullmatch(r"[A-Za-z0-9_-]{24}", hello["k"]), "18 random bytes, base64url"
180
180
 
@@ -345,6 +345,32 @@ async def conformance(rig: Rig, connector) -> None:
345
345
  assert (await client.call("POST", mk(), {"name": "..\\escape"}))["st"] == 400
346
346
  assert (await client.call("POST", mk(), {"nope": 1}))["st"] == 400
347
347
  assert (await client.call("POST", "/witbitz/mkdir?directory=rel&path=.", {"name": "x"}))["st"] == 400
348
+
349
+ # FILES (files.py = tools/code-files.mjs; the owner, 2026-10-24: "an upload and download menu"): Download any file of the
350
+ # session's folder — not a secret, not outside it — and Upload into it, never over a file unasked. The same on both.
351
+ fw = Path(tempfile.mkdtemp(prefix="wb-conf-files-")) / "proj"
352
+ (fw / "src").mkdir(parents=True)
353
+ (fw / "data.bin").write_bytes(b"\x00\x01\x02")
354
+ (fw / ".env").write_text("TOKEN=x")
355
+ (fw.parent / "outside.txt").write_text("no")
356
+ rig.oc.session_dirs["ses_files"] = str(fw)
357
+ dq = lambda p: f"/witbitz/download?session=ses_files&path={urllib.parse.quote(str(p), safe='')}" # noqa: E731
358
+ got = await client.call("GET", dq(fw / "data.bin"))
359
+ assert got["st"] == 200 and base64.b64decode(json.loads(got["b"])["b64"]) == b"\x00\x01\x02", got
360
+ assert (await client.call("GET", dq(fw / ".env")))["st"] == 403
361
+ assert (await client.call("GET", dq(fw.parent / "outside.txt")))["st"] == 403
362
+ up = lambda body: client.call("POST", "/witbitz/upload?session=ses_files", body) # noqa: E731
363
+ b64 = lambda s: base64.b64encode(s).decode() # noqa: E731
364
+ made = await up({"dir": str(fw / "src"), "name": "John's notes, v2.md", "b64": b64(b"# hi")})
365
+ assert made["st"] == 200 and (fw / "src" / "John's notes, v2.md").read_bytes() == b"# hi", made
366
+ taken = await up({"dir": str(fw), "name": "data.bin", "b64": b64(b"new")})
367
+ assert taken["st"] == 409 and json.loads(taken["b"])["replaceable"] is True and (fw / "data.bin").read_bytes() == b"\x00\x01\x02"
368
+ assert (await up({"dir": str(fw), "name": "data.bin", "b64": b64(b"new"), "replace": True}))["st"] == 200
369
+ assert (fw / "data.bin").read_bytes() == b"new"
370
+ assert (await up({"dir": str(fw.parent), "name": "x.txt", "b64": b64(b"x")}))["st"] == 403
371
+ assert (await up({"dir": str(fw), "name": "src", "b64": b64(b"x"), "replace": True}))["st"] == 409
372
+ assert (await up("not an object"))["st"] == 400
373
+ assert (await client.call("GET", "/witbitz/download?session=ses_nope&path=%2Fx"))["st"] == 404
348
374
  assert not any(h.get("url", "").startswith("/witbitz") for h in rig.oc.seen_copy()), "OpenCode never sees the routes"
349
375
  finally:
350
376
  await client.peer.aclose()
@@ -0,0 +1,71 @@
1
+ """The Files sheet's Download and Upload — files.py, held to tools/code-files.mjs.
2
+
3
+ The JS module is the reference: the same folder tree and the same requests must get the same answers from both connectors,
4
+ so the sheet behaves the same whichever connector a computer runs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json
11
+ import os
12
+
13
+ from conftest import requires_node, run_node
14
+
15
+ from witbitz_code.files import download_file, is_upload_name, upload_file
16
+
17
+ NAMES = ["John's report, v2 + notes #1 & more.pdf", "a+b.txt", "שלום.txt", "../x", "a/b", "a\\b", ".hidden", "", "..",
18
+ "x​.txt", "x‮.txt", " x.txt", "a" * 201]
19
+
20
+
21
+ def _tree(tmp_path):
22
+ d = tmp_path / "proj"
23
+ out = tmp_path / "elsewhere"
24
+ (d / "src").mkdir(parents=True)
25
+ out.mkdir()
26
+ (d / "report.xlsx").write_bytes(bytes([1, 2, 3, 4]))
27
+ (d / ".env").write_text("TOKEN=x")
28
+ (out / "private.txt").write_text("not yours")
29
+ os.symlink(out, d / "link-out")
30
+ return str(d), str(out)
31
+
32
+
33
+ def _b64(s: str) -> str:
34
+ return base64.b64encode(s.encode()).decode()
35
+
36
+
37
+ def _j(r):
38
+ return r[0], json.loads(r[1])
39
+
40
+
41
+ def test_download_any_file_but_never_a_secret_or_outside(tmp_path):
42
+ d, out = _tree(tmp_path)
43
+ st, b = _j(download_file(directory=d, path=f"{d}/report.xlsx"))
44
+ assert st == 200 and base64.b64decode(b["b64"]) == bytes([1, 2, 3, 4]) and b["kind"] == "file"
45
+ assert _j(download_file(directory=d, path=f"{d}/.env"))[0] == 403
46
+ assert _j(download_file(directory=d, path=f"{out}/private.txt"))[0] == 403
47
+ assert _j(download_file(directory=d, path=f"{d}/link-out/private.txt"))[0] == 403
48
+
49
+
50
+ def test_upload_creates_refuses_overwrite_and_replaces_when_asked(tmp_path):
51
+ d, out = _tree(tmp_path)
52
+ st, b = _j(upload_file(directory=d, dir=f"{d}/src", name="notes.md", b64=_b64("# hi")))
53
+ assert st == 200 and (tmp_path / "proj" / "src" / "notes.md").read_text() == "# hi" and b["replaced"] is False
54
+ st, b = _j(upload_file(directory=d, dir=d, name="report.xlsx", b64=_b64("new")))
55
+ assert st == 409 and b["replaceable"] is True and (tmp_path / "proj" / "report.xlsx").read_bytes() == bytes([1, 2, 3, 4])
56
+ assert _j(upload_file(directory=d, dir=d, name="src", b64=_b64("x"), replace=True))[1]["replaceable"] is False
57
+ assert _j(upload_file(directory=d, dir=d, name="report.xlsx", b64=_b64("new"), replace=True))[0] == 200
58
+ assert (tmp_path / "proj" / "report.xlsx").read_text() == "new"
59
+ assert _j(upload_file(directory=d, dir=f"{d}/link-out", name="x.txt", b64=_b64("x")))[0] == 403
60
+ assert not (tmp_path / "elsewhere" / "x.txt").exists()
61
+ assert _j(upload_file(directory=d, dir=d, name="x.txt", b64="not base64!"))[0] == 400
62
+ assert not [f for f in os.listdir(d) if ".upload-" in f], "no temporary left behind"
63
+
64
+
65
+ @requires_node
66
+ def test_parity_with_the_js_connector(tmp_path):
67
+ js = run_node("""
68
+ import { isUploadName } from '@TOOLS@/code-files.mjs'
69
+ OUT(INPUT.names.map((n) => isUploadName(n)))
70
+ """, {"names": NAMES})
71
+ assert [is_upload_name(n) for n in NAMES] == js
File without changes
File without changes
File without changes