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.
- {witbitz_code-1.13.0/src/witbitz_code.egg-info → witbitz_code-1.14.0}/PKG-INFO +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/pyproject.toml +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/connector.py +35 -1
- witbitz_code-1.14.0/src/witbitz_code/files.py +110 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/outputs.py +8 -4
- {witbitz_code-1.13.0 → witbitz_code-1.14.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_auto.py +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_connector_e2e.py +27 -1
- witbitz_code-1.14.0/tests/test_files.py +71 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/LICENSE +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/README.md +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/setup.cfg +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/app.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/browse.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/cli.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/app.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_browse.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_images.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_selfupdate.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ui.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_ui_page.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.14.0}/tests/test_wslstart.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.14.0"
|
|
8
8
|
description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -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
|
|
75
|
-
|
|
74
|
+
def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: int = MAX_SERVE_BYTES,
|
|
75
|
+
any_kind: bool = False) -> tuple[int, str]:
|
|
76
|
+
"""One produced file (or its facts, with `stat`) for the page: (status, JSON body) in the connector's reply shape.
|
|
77
|
+
|
|
78
|
+
`any_kind`: the Files sheet's Download (files.py) — any regular file, not only a kind the page previews; every other
|
|
79
|
+
check stays (⇄ tools/code-outputs.mjs)."""
|
|
76
80
|
if not isinstance(directory, str) or not directory.startswith("/") or not valid_output_path(path):
|
|
77
81
|
return _answer(400, {"error": "not a file in this session"})
|
|
78
82
|
d = re.sub(r"/+\Z", "", posix_normalize(directory))
|
|
@@ -81,7 +85,7 @@ def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: in
|
|
|
81
85
|
return _answer(403, {"error": "that file is outside the session's folder"})
|
|
82
86
|
if _sensitive_path(a[len(d):]):
|
|
83
87
|
return _answer(403, {"error": "not shown: the name looks like a secret"})
|
|
84
|
-
if not output_kind(a):
|
|
88
|
+
if not any_kind and not output_kind(a):
|
|
85
89
|
return _answer(400, {"error": "not a file the page shows"})
|
|
86
90
|
try:
|
|
87
91
|
real_dir = os.path.realpath(d, strict=True)
|
|
@@ -95,7 +99,7 @@ def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: in
|
|
|
95
99
|
return _answer(403, {"error": "that file leads outside the session's folder"})
|
|
96
100
|
if _sensitive_path(real[len(real_dir):]):
|
|
97
101
|
return _answer(403, {"error": "not shown: the file looks like a secret"})
|
|
98
|
-
kind = output_kind(real)
|
|
102
|
+
kind = output_kind(real) or ("file" if any_kind else None)
|
|
99
103
|
if not kind:
|
|
100
104
|
return _answer(400, {"error": "not a file the page shows"})
|
|
101
105
|
try:
|
|
@@ -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)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js
RENAMED
|
File without changes
|
|
File without changes
|
{witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.13.0 → witbitz_code-1.14.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|