witbitz-code 1.13.0__tar.gz → 1.15.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.15.0}/PKG-INFO +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/pyproject.toml +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/connector.py +42 -5
- witbitz_code-1.15.0/src/witbitz_code/files.py +110 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +32 -4
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js +2 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/outputs.py +11 -5
- {witbitz_code-1.13.0 → witbitz_code-1.15.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_auto.py +1 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_connector_e2e.py +27 -1
- witbitz_code-1.15.0/tests/test_files.py +71 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_outputs.py +30 -1
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/LICENSE +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/README.md +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/setup.cfg +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/app.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/browse.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/browsetree.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/cli.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/remind.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/selfupdate.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/ui.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/ui_static/app.js +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_browse.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_browsetree.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_images.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_remind.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_selfupdate.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_setup.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_ui.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_ui_page.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_uitools.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_winbg.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.0}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.13.0 → witbitz_code-1.15.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.15.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
|
|
@@ -163,9 +164,11 @@ class PairingServer:
|
|
|
163
164
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
164
165
|
model_url: str = "", remind_times: dict | None = None,
|
|
165
166
|
browse_probe: Callable[[], dict | None] | None = None,
|
|
166
|
-
browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> None:
|
|
167
|
+
browse_private: Callable[[bool], Awaitable[bool]] | None = None, screens_root: Path | None = None) -> None:
|
|
167
168
|
self.pairing = pairing
|
|
168
169
|
self._browse_private = browse_private # private browsing on/off (browse.py LocalBrowser.set_private)
|
|
170
|
+
# the agent's browser_screenshot writes <home>/.witbitz/screens/<session> (browse-cdp.mjs), served beside the session's folder
|
|
171
|
+
self._screens_root = Path(screens_root) if screens_root else Path.home() / ".witbitz" / "screens"
|
|
169
172
|
# THE AGENT'S BROWSER ON THIS COMPUTER (browse.py): its /status rides the hello; a change is announced within 2 s
|
|
170
173
|
self._browse_probe = browse_probe
|
|
171
174
|
self._browse_said = "" # the last browse report the page was told, as JSON
|
|
@@ -318,7 +321,7 @@ class PairingServer:
|
|
|
318
321
|
self._browse_said = json.dumps(browse, sort_keys=True)
|
|
319
322
|
# caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
|
|
320
323
|
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(),
|
|
324
|
+
"k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind", "files"], "auto": self.auto.sessions(),
|
|
322
325
|
**({"browse": browse} if browse else {})})
|
|
323
326
|
|
|
324
327
|
def _auth(self) -> dict:
|
|
@@ -412,11 +415,45 @@ class PairingServer:
|
|
|
412
415
|
await reply(404, {"error": "no such session on this computer"})
|
|
413
416
|
return
|
|
414
417
|
stat_only = q.get("stat") == "1"
|
|
415
|
-
st, b = serve_output(directory=session["directory"], path=q.get("path"), stat=stat_only
|
|
418
|
+
st, b = serve_output(directory=session["directory"], path=q.get("path"), stat=stat_only,
|
|
419
|
+
shots=(self._screens_root / sid).as_posix())
|
|
416
420
|
self._log_output({"at": int(time.time() * 1000), "session": sid, "digest": hashlib.sha256(q.get("path", "").encode()).hexdigest(),
|
|
417
421
|
"stat": stat_only, "st": st})
|
|
418
422
|
await reply(st, b)
|
|
419
423
|
return
|
|
424
|
+
# The Files sheet (files.py ⇄ tools/code-files.mjs): Download any file of the session's folder — nothing secret-looking —
|
|
425
|
+
# and Upload into it. Answered HERE; the folder is the session's, as OpenCode reports it; logged as a digest of the path.
|
|
426
|
+
if (m.get("m") == "GET" and bare == FILES_DOWNLOAD_ROUTE) or (m.get("m") == "POST" and bare == FILES_UPLOAD_ROUTE):
|
|
427
|
+
q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
|
|
428
|
+
sid = q.get("session", "")
|
|
429
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", sid):
|
|
430
|
+
await reply(400, {"error": "not a session id"})
|
|
431
|
+
return
|
|
432
|
+
dir_query = "directory=" + urllib.parse.quote(q["directory"], safe="!'()*") if q.get("directory") else "" # encodeURIComponent
|
|
433
|
+
session = await self._session_for(sid, dir_query)
|
|
434
|
+
if not session or not isinstance(session.get("directory"), str):
|
|
435
|
+
await reply(404, {"error": "no such session on this computer"})
|
|
436
|
+
return
|
|
437
|
+
now = int(time.time() * 1000)
|
|
438
|
+
if m.get("m") == "GET":
|
|
439
|
+
st, b = download_file(directory=session["directory"], path=q.get("path"))
|
|
440
|
+
self._log_output({"at": now, "session": sid, "digest": hashlib.sha256(q.get("path", "").encode()).hexdigest(),
|
|
441
|
+
"download": True, "st": st})
|
|
442
|
+
await reply(st, b)
|
|
443
|
+
return
|
|
444
|
+
try:
|
|
445
|
+
f = json.loads(m.get("b") or "null")
|
|
446
|
+
except ValueError:
|
|
447
|
+
f = None
|
|
448
|
+
if not isinstance(f, dict):
|
|
449
|
+
await reply(400, {"error": "no file"})
|
|
450
|
+
return
|
|
451
|
+
st, b = upload_file(directory=session["directory"], dir=f.get("dir"), name=f.get("name"), b64=f.get("b64"),
|
|
452
|
+
replace=f.get("replace") is True)
|
|
453
|
+
self._log_output({"at": now, "session": sid, "digest": hashlib.sha256(f"{f.get('dir')}/{f.get('name')}".encode()).hexdigest(),
|
|
454
|
+
"upload": True, "st": st})
|
|
455
|
+
await reply(st, b)
|
|
456
|
+
return
|
|
420
457
|
# What the person has seen, shared by their devices (seen.py): answered HERE and kept on this computer.
|
|
421
458
|
if bare == SEEN_ROUTE and m.get("m") in ("GET", "POST"):
|
|
422
459
|
if m.get("m") == "GET":
|
|
@@ -973,7 +1010,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
973
1010
|
notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
|
|
974
1011
|
model_url: str = "", remind_times: dict | None = None,
|
|
975
1012
|
browse_probe: Callable[[], dict | None] | None = None,
|
|
976
|
-
browse_private: Callable[[bool], Awaitable[bool]] | None = None) -> Connector:
|
|
1013
|
+
browse_private: Callable[[bool], Awaitable[bool]] | None = None, screens_root: Path | None = None) -> Connector:
|
|
977
1014
|
"""Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
|
|
978
1015
|
request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
|
|
979
1016
|
client = client or local_client()
|
|
@@ -989,7 +1026,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
|
|
|
989
1026
|
max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
|
|
990
1027
|
attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
|
|
991
1028
|
notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times,
|
|
992
|
-
browse_probe=browse_probe, browse_private=browse_private)
|
|
1029
|
+
browse_probe=browse_probe, browse_private=browse_private, screens_root=screens_root)
|
|
993
1030
|
await s.start()
|
|
994
1031
|
servers.append(s)
|
|
995
1032
|
return Connector(servers, client, payers)
|
|
@@ -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})
|
{witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs
RENAMED
|
@@ -101,7 +101,24 @@ async function snapshot (c, limit = 120) {
|
|
|
101
101
|
// The refs are only meaningful next to the snapshot that made them, so they travel with it rather than living in
|
|
102
102
|
// a cache that a page navigation would quietly invalidate.
|
|
103
103
|
const REFS_FILE = (process.env.TMPDIR || '/tmp') + '/wb-browse-refs.json'
|
|
104
|
-
const { writeFileSync, readFileSync } = await import('node:fs')
|
|
104
|
+
const { writeFileSync, readFileSync, mkdirSync, readdirSync, statSync, rmSync } = await import('node:fs')
|
|
105
|
+
const { join } = await import('node:path')
|
|
106
|
+
const { homedir } = await import('node:os')
|
|
107
|
+
|
|
108
|
+
// THE SCREENSHOTS' FOLDER: <home>/.witbitz/screens/<session> (spaces/public/codeOutputs.js SHOTS_DIR) — the agent's home, as
|
|
109
|
+
// the connector reads it (on a cloud computer /rw/home, on a laptop the person's own). WB_BROWSE_SCREENS moves the root.
|
|
110
|
+
const SHOTS_ROOT = process.env.WB_BROWSE_SCREENS || join(homedir(), '.witbitz', 'screens')
|
|
111
|
+
const shotsDir = (sid) => join(SHOTS_ROOT, sid)
|
|
112
|
+
const SHOT_DAYS = 30
|
|
113
|
+
/** A session's pictures go 30 days after its last one — attachments' rule (tools/code-attachments.mjs). */
|
|
114
|
+
function pruneShots () {
|
|
115
|
+
try {
|
|
116
|
+
for (const n of readdirSync(SHOTS_ROOT)) {
|
|
117
|
+
const d = join(SHOTS_ROOT, n)
|
|
118
|
+
try { if (Date.now() - statSync(d).mtimeMs > SHOT_DAYS * 86400000) rmSync(d, { recursive: true, force: true }) } catch { /* */ }
|
|
119
|
+
}
|
|
120
|
+
} catch { /* none yet */ }
|
|
121
|
+
}
|
|
105
122
|
|
|
106
123
|
// ★ A MISSING refs file is the SAME situation as a stale ref: the agent has not taken a snapshot yet. Letting
|
|
107
124
|
// readFileSync throw turned that into "ENOENT: no such file or directory", which tells a model nothing it can act
|
|
@@ -226,11 +243,22 @@ try {
|
|
|
226
243
|
await new Promise((r) => setTimeout(r, 400))
|
|
227
244
|
} else if (op === 'screenshot') {
|
|
228
245
|
const { data } = await c.send('Page.captureScreenshot', { format: 'jpeg', quality: 70 })
|
|
229
|
-
|
|
230
|
-
|
|
246
|
+
// ★ INTO WITBITZ'S OWN FOLDER, ONE PER SESSION (the owner, 2026-10-25: "how can I get a screen snapshot in the chat"):
|
|
247
|
+
// <home>/.witbitz/screens/<session>/ — the connector serves that folder for that session as well as the session's own
|
|
248
|
+
// (tools/code-outputs.mjs), so the picture shows under the agent's answer, and nothing lands in the project. In a temp
|
|
249
|
+
// folder the page could never be shown it: the connector serves nothing outside a session's folders.
|
|
250
|
+
let path = args.path
|
|
251
|
+
if (!path) {
|
|
252
|
+
const sid = /^[A-Za-z0-9_-]{1,128}$/.test(String(args.session || '')) ? args.session : 'no-session'
|
|
253
|
+
const dir = shotsDir(sid)
|
|
254
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
255
|
+
pruneShots()
|
|
256
|
+
path = join(dir, 'screen-' + new Date().toISOString().replace(/[:.]/g, '-') + '.jpg')
|
|
257
|
+
}
|
|
258
|
+
writeFileSync(path, Buffer.from(data, 'base64'), { mode: 0o600 })
|
|
231
259
|
const { lines } = await snapshot(c, 0).catch(() => ({ lines: [] }))
|
|
232
260
|
c.close()
|
|
233
|
-
out({ ok: true, path, note: 'a picture for a person to look at; act from a snapshot, not from this' })
|
|
261
|
+
out({ ok: true, path, shot: true, note: 'a picture for a person to look at; act from a snapshot, not from this' })
|
|
234
262
|
} else if (op !== 'snapshot') {
|
|
235
263
|
out({ error: 'unknown op: ' + op })
|
|
236
264
|
}
|
{witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-browser.js
RENAMED
|
@@ -43,6 +43,7 @@ function render (j) {
|
|
|
43
43
|
if (!j) return 'The browser did not answer.'
|
|
44
44
|
if (j.error) return 'Browser: ' + j.error
|
|
45
45
|
if (j.closed) return 'The browser is closed — its tabs and any sign-ins are gone. browser_open or browser_navigate starts a fresh one.'
|
|
46
|
+
if (j.shot && j.path) return 'Saved a picture of the page: ' + j.path + ' — the person sees it under your answer.'
|
|
46
47
|
if (j.cleared) return 'Cleared: the browser\'s history, cookies, cache and site data are gone, and a fresh browser is open — browser_navigate to go on.'
|
|
47
48
|
if (j.ok && !j.title && !j.url && !(j.elements && j.elements.length) && !j.path) return 'The browser is open' + (j.headed === false ? ' (headless — see it with browser_screenshot)' : j.headed ? ' (a window on this computer\'s screen)' : '') + '. Navigate to a URL next.' // the open op: nothing on screen yet
|
|
48
49
|
const head = [j.title ? 'Title: ' + j.title : null, j.url ? 'URL: ' + j.url : null].filter(Boolean).join('\n')
|
|
@@ -157,7 +158,7 @@ export const WitbitzBrowser = async (ctx = {}) => {
|
|
|
157
158
|
browser_screenshot: tool({
|
|
158
159
|
description: 'Save a picture of the page for the PERSON to look at, and return its path. Do not use this to decide what to click — use a snapshot.',
|
|
159
160
|
args: {},
|
|
160
|
-
execute: () => run('screenshot', {}),
|
|
161
|
+
execute: (args, context) => run('screenshot', { session: (context && context.sessionID) || '' }),
|
|
161
162
|
}),
|
|
162
163
|
},
|
|
163
164
|
}
|
|
@@ -71,17 +71,23 @@ 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, shots: str = "") -> 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). `shots`: the session's screenshots folder (<home>/.witbitz/screens/<session>,
|
|
80
|
+
browse-cdp.mjs) — served beside its folder with every same check."""
|
|
76
81
|
if not isinstance(directory, str) or not directory.startswith("/") or not valid_output_path(path):
|
|
77
82
|
return _answer(400, {"error": "not a file in this session"})
|
|
78
|
-
d = re.sub(r"/+\Z", "", posix_normalize(directory))
|
|
79
83
|
a = posix_normalize(path)
|
|
84
|
+
shot_dir = re.sub(r"/+\Z", "", posix_normalize(shots)) if isinstance(shots, str) and shots.startswith("/") else ""
|
|
85
|
+
d = shot_dir if shot_dir and a.startswith(shot_dir + "/") else re.sub(r"/+\Z", "", posix_normalize(directory))
|
|
80
86
|
if not d or not a.startswith(d + "/"):
|
|
81
87
|
return _answer(403, {"error": "that file is outside the session's folder"})
|
|
82
88
|
if _sensitive_path(a[len(d):]):
|
|
83
89
|
return _answer(403, {"error": "not shown: the name looks like a secret"})
|
|
84
|
-
if not output_kind(a):
|
|
90
|
+
if not any_kind and not output_kind(a):
|
|
85
91
|
return _answer(400, {"error": "not a file the page shows"})
|
|
86
92
|
try:
|
|
87
93
|
real_dir = os.path.realpath(d, strict=True)
|
|
@@ -95,7 +101,7 @@ def serve_output(*, directory: Any, path: Any, stat: bool = False, max_bytes: in
|
|
|
95
101
|
return _answer(403, {"error": "that file leads outside the session's folder"})
|
|
96
102
|
if _sensitive_path(real[len(real_dir):]):
|
|
97
103
|
return _answer(403, {"error": "not shown: the file looks like a secret"})
|
|
98
|
-
kind = output_kind(real)
|
|
104
|
+
kind = output_kind(real) or ("file" if any_kind else None)
|
|
99
105
|
if not kind:
|
|
100
106
|
return _answer(400, {"error": "not a file the page shows"})
|
|
101
107
|
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
|
|
@@ -67,11 +67,30 @@ def _requests(d: Path, outside: Path, root: Path) -> list[dict]:
|
|
|
67
67
|
{"directory": str(d), "path": str(d / "out" / "big.pdf"), "stat": False, "maxBytes": 1024},
|
|
68
68
|
{"directory": str(d), "path": str(d / "out" / "big.pdf"), "stat": True, "maxBytes": 1024},
|
|
69
69
|
{"directory": str(d), "path": None, "stat": False}]
|
|
70
|
+
# the agent's screenshots: <home>/.witbitz/screens/<session>, served for that session only (2026-10-25)
|
|
71
|
+
mine, theirs = root / "screens" / "ses_A", root / "screens" / "ses_B"
|
|
72
|
+
reqs += [{"directory": str(d), "path": str(mine / "screen-1.jpg"), "stat": False, "shots": str(mine)},
|
|
73
|
+
{"directory": str(d), "path": str(theirs / "screen-2.jpg"), "stat": False, "shots": str(mine)},
|
|
74
|
+
{"directory": str(d), "path": str(mine / "screen-1.jpg"), "stat": False},
|
|
75
|
+
{"directory": str(d), "path": str(mine / "out.pdf"), "stat": False, "shots": str(mine)},
|
|
76
|
+
{"directory": str(d), "path": str(d / "out" / "chart.png"), "stat": False, "shots": str(mine)}]
|
|
70
77
|
return reqs
|
|
71
78
|
|
|
72
79
|
|
|
80
|
+
def _screens(root: Path, outside: Path) -> None:
|
|
81
|
+
mine, theirs = root / "screens" / "ses_A", root / "screens" / "ses_B"
|
|
82
|
+
mine.mkdir(parents=True)
|
|
83
|
+
theirs.mkdir(parents=True)
|
|
84
|
+
for p, b in [(mine / "screen-1.jpg", b"JPEGA"), (theirs / "screen-2.jpg", b"JPEGB")]:
|
|
85
|
+
p.write_bytes(b)
|
|
86
|
+
os.utime(p, (1789000000, 1789000000))
|
|
87
|
+
(mine / "out.pdf").symlink_to(outside / "report.pdf")
|
|
88
|
+
|
|
89
|
+
|
|
73
90
|
def _py(r: dict) -> list:
|
|
74
91
|
kw = {"max_bytes": r["maxBytes"]} if "maxBytes" in r else {}
|
|
92
|
+
if "shots" in r:
|
|
93
|
+
kw["shots"] = r["shots"]
|
|
75
94
|
st, b = serve_output(directory=r["directory"], path=r["path"], stat=r["stat"], **kw)
|
|
76
95
|
return [st, json.loads(b)]
|
|
77
96
|
|
|
@@ -92,12 +111,13 @@ def test_what_the_python_connector_serves(tmp_path):
|
|
|
92
111
|
@requires_node
|
|
93
112
|
def test_parity_with_the_js_connector(tmp_path):
|
|
94
113
|
d, outside = _tree(tmp_path)
|
|
114
|
+
_screens(tmp_path, outside)
|
|
95
115
|
reqs = _requests(d, outside, tmp_path)
|
|
96
116
|
js = run_node("""
|
|
97
117
|
import { serveOutput } from '@TOOLS@/code-outputs.mjs'
|
|
98
118
|
import { outputMime } from '@PUBLIC@/codeOutputs.js'
|
|
99
119
|
OUT({
|
|
100
|
-
served: INPUT.reqs.map((r) => { const o = serveOutput({ directory: r.directory, path: r.path, stat: r.stat, ...(r.maxBytes ? { maxBytes: r.maxBytes } : {}) }); return [o.st, JSON.parse(o.b)] }),
|
|
120
|
+
served: INPUT.reqs.map((r) => { const o = serveOutput({ directory: r.directory, path: r.path, stat: r.stat, ...(r.maxBytes ? { maxBytes: r.maxBytes } : {}), ...(r.shots ? { shots: r.shots } : {}) }); return [o.st, JSON.parse(o.b)] }),
|
|
101
121
|
mimes: INPUT.exts.map((e) => outputMime('/a/x.' + e)),
|
|
102
122
|
})
|
|
103
123
|
""", {"reqs": reqs, "exts": EXTS})
|
|
@@ -108,3 +128,12 @@ def test_parity_with_the_js_connector(tmp_path):
|
|
|
108
128
|
|
|
109
129
|
EXTS = ["pdf", "PNG", "jpg", "jpeg", "gif", "webp", "csv", "tsv", "md", "markdown", "txt", "docx", "doc", "xlsx", "xls", "pptx", "ppt",
|
|
110
130
|
"odt", "ods", "odp", "rtf", "zip", "epub", "heic", "svg", "html", "htm", "mp3", "wav", "m4a", "mp4", "mov", "ts", "json", ""]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_a_screenshot_is_served_for_its_own_session_only(tmp_path):
|
|
134
|
+
d, outside = _tree(tmp_path)
|
|
135
|
+
_screens(tmp_path, outside)
|
|
136
|
+
mine = tmp_path / "screens" / "ses_A"
|
|
137
|
+
assert _py({"directory": str(d), "path": str(mine / "screen-1.jpg"), "stat": False, "shots": str(mine)})[0] == 200
|
|
138
|
+
assert _py({"directory": str(d), "path": str(tmp_path / "screens" / "ses_B" / "screen-2.jpg"), "stat": False, "shots": str(mine)})[0] == 403
|
|
139
|
+
assert _py({"directory": str(d), "path": str(mine / "out.pdf"), "stat": False, "shots": str(mine)})[0] == 403, "a link out"
|
|
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
|
{witbitz_code-1.13.0 → witbitz_code-1.15.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.13.0 → witbitz_code-1.15.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
|