witbitz-code 1.6.9__tar.gz → 1.7.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 (104) hide show
  1. {witbitz_code-1.6.9/src/witbitz_code.egg-info → witbitz_code-1.7.0}/PKG-INFO +2 -1
  2. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/pyproject.toml +3 -1
  3. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/confidential.py +7 -1
  5. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/connector.py +11 -0
  6. witbitz_code-1.7.0/src/witbitz_code/images.py +125 -0
  7. witbitz_code-1.7.0/src/witbitz_code/seccheck.py +226 -0
  8. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/ui.py +6 -3
  9. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/ui_static/app.css +8 -0
  10. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/ui_static/app.js +41 -0
  11. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/ui_static/index.html +8 -0
  12. {witbitz_code-1.6.9 → witbitz_code-1.7.0/src/witbitz_code.egg-info}/PKG-INFO +2 -1
  13. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
  14. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/requires.txt +1 -0
  15. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_connector_e2e.py +23 -0
  16. witbitz_code-1.7.0/tests/test_images.py +102 -0
  17. witbitz_code-1.7.0/tests/test_seccheck.py +143 -0
  18. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/LICENSE +0 -0
  19. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/README.md +0 -0
  20. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/setup.cfg +0 -0
  21. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/__main__.py +0 -0
  22. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/_js.py +0 -0
  23. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/account.py +0 -0
  24. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/asks.py +0 -0
  25. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/attachments.py +0 -0
  26. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/auto.py +0 -0
  27. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/auto_runner.py +0 -0
  28. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/cli.py +0 -0
  29. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/codekeys.py +0 -0
  30. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/codetools.py +0 -0
  31. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/compress.py +0 -0
  32. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/computers.py +0 -0
  33. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/confidential_models.json +0 -0
  34. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/devicelink.py +0 -0
  35. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/dirs.py +0 -0
  36. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/folders.py +0 -0
  37. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/gateway.py +0 -0
  38. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  39. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  40. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/keys.py +0 -0
  41. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/link.py +0 -0
  42. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/mkdir.py +0 -0
  43. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/models.py +0 -0
  44. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/net.py +0 -0
  45. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/offered_models.json +0 -0
  46. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  47. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  48. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  49. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  50. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/outputs.py +0 -0
  51. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/pair.py +0 -0
  52. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/pairings.py +0 -0
  53. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/payers.py +0 -0
  54. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/plugins.py +0 -0
  55. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/policy.py +0 -0
  56. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/ports.py +0 -0
  57. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/qr.py +0 -0
  58. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/receipt.py +0 -0
  59. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/relay.py +0 -0
  60. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/seen.py +0 -0
  61. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/tinfoil_read.py +0 -0
  62. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/tools_catalog.json +0 -0
  63. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/tools_probe.py +0 -0
  64. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/toolsetup.py +0 -0
  65. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/uitools.py +0 -0
  66. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/uninstall.py +0 -0
  67. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/winagent.py +0 -0
  68. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/winbg.py +0 -0
  69. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/winconsole.py +0 -0
  70. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/wizard.py +0 -0
  71. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code/wslstart.py +0 -0
  72. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  73. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  74. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  75. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_asks.py +0 -0
  76. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_attachments.py +0 -0
  77. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_auto.py +0 -0
  78. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_cli.py +0 -0
  79. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_codekeys.py +0 -0
  80. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_codetools.py +0 -0
  81. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_confidential.py +0 -0
  82. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_devicelink.py +0 -0
  83. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_dirs.py +0 -0
  84. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_files_and_compress.py +0 -0
  85. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_folders.py +0 -0
  86. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_outputs.py +0 -0
  87. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_pair_flows.py +0 -0
  88. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_plugins.py +0 -0
  89. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_policy.py +0 -0
  90. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_ports.py +0 -0
  91. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_ports_win.py +0 -0
  92. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_registry.py +0 -0
  93. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_relay_codec.py +0 -0
  94. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_setup.py +0 -0
  95. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_tools_probe.py +0 -0
  96. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_toolsetup.py +0 -0
  97. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_ui.py +0 -0
  98. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_uitools.py +0 -0
  99. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_uninstall.py +0 -0
  100. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_winagent.py +0 -0
  101. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_winbg.py +0 -0
  102. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_winconsole.py +0 -0
  103. {witbitz_code-1.6.9 → witbitz_code-1.7.0}/tests/test_windows_files.py +0 -0
  104. {witbitz_code-1.6.9 → witbitz_code-1.7.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.6.9
3
+ Version: 1.7.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
@@ -19,6 +19,7 @@ Requires-Dist: websockets>=13
19
19
  Requires-Dist: httpx>=0.25
20
20
  Requires-Dist: segno>=1.5
21
21
  Requires-Dist: tinfoil<0.15,>=0.14
22
+ Requires-Dist: pillow>=10
22
23
  Provides-Extra: test
23
24
  Requires-Dist: pytest>=8; extra == "test"
24
25
  Requires-Dist: pyflakes>=3; extra == "test"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.6.9"
7
+ version = "1.7.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"
@@ -26,6 +26,8 @@ dependencies = [
26
26
  # Tinfoil's own verifier (attestation + Sigstore + TLS pinning) — the confidential proxy reads images and files a
27
27
  # text-only confidential model cannot, in Tinfoil's attested enclave (tinfoil_read.py). Pinned to a series on purpose.
28
28
  "tinfoil>=0.14,<0.15",
29
+ # Older screenshots are re-encoded smaller before a request leaves (images.py): TrustedRouter drops a ~10 MB request.
30
+ "pillow>=10",
29
31
  ]
30
32
 
31
33
  [project.optional-dependencies]
@@ -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.6.9"
7
+ __version__ = "1.7.0"
@@ -26,7 +26,7 @@ from typing import Any, Callable
26
26
 
27
27
  import httpx
28
28
 
29
- from . import keys, models as mdl, receipt as rcp, tinfoil_read
29
+ from . import images, keys, models as mdl, receipt as rcp, tinfoil_read
30
30
  from .gateway import verify_gateway_attestation
31
31
 
32
32
  TR_UPSTREAM = "https://api.trustedrouter.com"
@@ -577,6 +577,12 @@ class ConfidentialProxy:
577
577
  return
578
578
  req.headers = [(k, v) for k, v in req.headers if k.lower() not in ("authorization", "x-api-key")] + [("authorization", "Bearer " + cred["trustedrouter"])]
579
579
  tinfoil = cred.get("tinfoil") or ""
580
+ if isinstance(body, dict):
581
+ shrunk, rep = await asyncio.to_thread(images.shrink_older_images, body)
582
+ if shrunk is not body:
583
+ body, req.body = shrunk, dumps(shrunk).encode("utf-8")
584
+ if rep["dropped"]:
585
+ self.log(f"code-confidential: the request was {rep['before'] // 1024} KB; {rep['dropped']} older screenshot(s) left out to send it")
580
586
  conf = self.models.get(body.get("model")) if isinstance(body, dict) and isinstance(body.get("model"), str) else None
581
587
  if not conf:
582
588
  await self._pass_through(req, res, body, target)
@@ -41,6 +41,8 @@ from .plugins import notes_plugin_path as default_notes_plugin, notes_root as de
41
41
  from .payers import SessionPayers
42
42
  from .seen import SEEN_ROUTE, merge_seen, norm_seen
43
43
  from .dirs import DIRS_ROUTE, list_dirs
44
+ from . import seccheck
45
+ from .seccheck import SECURITY_ROUTE
44
46
  from .mkdir import MKDIR_ROUTE, mkdir_in
45
47
  from .pairings import DEFAULT_OPENCODE_URL, hostname, read_env_password
46
48
  from .relay import RELAY_URL, Connect, RelayPeer, allowed_event_path, allowed_request, project_response
@@ -157,6 +159,7 @@ class PairingServer:
157
159
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
158
160
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> None:
159
161
  self.pairing = pairing
162
+ self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
160
163
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
161
164
  self._payers = payers
162
165
  self._cid = _js.js_string(pairing.get("computerId") or "")
@@ -407,6 +410,14 @@ class PairingServer:
407
410
  st, b = list_dirs(path=q.get("path", ""))
408
411
  await reply(st, b)
409
412
  return
413
+ # "THIS COMPUTER'S SECURITY" (seccheck.py): the same check as the witbitz-code page, for the account's own Spaces app —
414
+ # read here, sealed through the relay like every answer, never written to the account's stores. Python only (the Node
415
+ # download answers 404 and the page says the check is not available there).
416
+ if bare == SECURITY_ROUTE and m.get("m") == "GET":
417
+ if self._security is None:
418
+ self._security = seccheck.SecurityCheck()
419
+ await reply(200, await asyncio.to_thread(self._security.status))
420
+ return
410
421
  # "NEW FOLDER" (mkdir.py = tools/code-mkdir.mjs): one clean leaf under the folder the picker shows. Answered HERE.
411
422
  if bare == MKDIR_ROUTE and m.get("m") == "POST":
412
423
  q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
@@ -0,0 +1,125 @@
1
+ """OLDER IMAGES ARE SHRUNK BEFORE A REQUEST LEAVES THIS COMPUTER.
2
+
3
+ OpenCode sends the whole conversation on every call, and every screenshot in it rides along as a full base64 PNG. A model
4
+ reads an image as a fixed number of tokens whatever its bytes, so the context stays small while the REQUEST grows with each
5
+ screenshot — and TrustedRouter drops a request of about 10 MB without answering (measured 2026-10-12: 9 MB answered,
6
+ 10 MB closed the connection; a session with 18 screenshots reached 11 MB and every message in it failed).
7
+
8
+ So: the newest KEEP_FULL images go as they are; every older one is re-encoded as a JPEG no larger than MAX_SIDE on its
9
+ longest side. A screenshot of ~1 MB becomes ~100–200 KB and still reads. The result is cached by content, so the same old
10
+ screenshot shrinks to the same bytes on every call (a provider's prompt cache keeps working from there on). If the request
11
+ is STILL over BUDGET, the oldest images are replaced by a one-line note, newest kept — a request that cannot be sent helps
12
+ no one. No image library, or an image it cannot decode: that image is left as it is (the budget rule still applies).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import hashlib
19
+ import io
20
+ import json
21
+ import re
22
+ from collections import OrderedDict
23
+ from typing import Any
24
+
25
+ KEEP_FULL = 3
26
+ MAX_SIDE = 1280
27
+ JPEG_QUALITY = 70
28
+ BUDGET = 8 * 1024 * 1024 # bytes of request body; TrustedRouter's limit is ~10 MB
29
+ CACHE_MAX = 256
30
+ _DATA_URL = re.compile(r"^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$", re.S)
31
+
32
+ _cache: "OrderedDict[str, str | None]" = OrderedDict()
33
+
34
+
35
+ def _image_parts(body: dict) -> list[tuple[int, int]]:
36
+ """(message, part) of every inline image, oldest first."""
37
+ out = []
38
+ for mi, msg in enumerate(body.get("messages") or []):
39
+ content = msg.get("content") if isinstance(msg, dict) else None
40
+ if not isinstance(content, list):
41
+ continue
42
+ for pi, part in enumerate(content):
43
+ if isinstance(part, dict) and part.get("type") == "image_url" and _DATA_URL.match(str((part.get("image_url") or {}).get("url") or "")):
44
+ out.append((mi, pi))
45
+ return out
46
+
47
+
48
+ def shrink_data_url(url: str) -> str | None:
49
+ """A smaller JPEG data URL for this image, or None (no library, cannot decode, or not smaller)."""
50
+ key = hashlib.sha256(url.encode("utf-8", "replace")).hexdigest()
51
+ if key in _cache:
52
+ _cache.move_to_end(key)
53
+ return _cache[key]
54
+ out = None
55
+ try:
56
+ from PIL import Image # optional: without it, images go as they are
57
+ m = _DATA_URL.match(url)
58
+ raw = base64.b64decode(m.group(2), validate=False)
59
+ with Image.open(io.BytesIO(raw)) as im:
60
+ im.load()
61
+ im = im.convert("RGBA") if im.mode in ("P", "LA") else im
62
+ if im.mode == "RGBA":
63
+ bg = Image.new("RGB", im.size, (255, 255, 255))
64
+ bg.paste(im, mask=im.split()[3])
65
+ im = bg
66
+ elif im.mode != "RGB":
67
+ im = im.convert("RGB")
68
+ if max(im.size) > MAX_SIDE:
69
+ im.thumbnail((MAX_SIDE, MAX_SIDE), Image.LANCZOS)
70
+ buf = io.BytesIO()
71
+ im.save(buf, "JPEG", quality=JPEG_QUALITY, optimize=True)
72
+ small = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
73
+ out = small if len(small) < len(url) else None
74
+ except Exception: # noqa: BLE001 — an image we cannot shrink is sent as it is
75
+ out = None
76
+ _cache[key] = out
77
+ while len(_cache) > CACHE_MAX:
78
+ _cache.popitem(last=False)
79
+ return out
80
+
81
+
82
+ def _size(body: dict) -> int:
83
+ return len(json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
84
+
85
+
86
+ def shrink_older_images(body: Any, keep_full: int = KEEP_FULL, budget: int = BUDGET) -> tuple[Any, dict]:
87
+ """(body, report). The body is a NEW dict when anything changed; the input is never mutated."""
88
+ report = {"shrunk": 0, "dropped": 0, "before": 0, "after": 0}
89
+ if not isinstance(body, dict):
90
+ return body, report
91
+ parts = _image_parts(body)
92
+ if not parts:
93
+ return body, report
94
+ messages = list(body["messages"])
95
+ touched: set[int] = set()
96
+
97
+ def put(mi: int, pi: int, part: dict) -> None:
98
+ if mi not in touched:
99
+ messages[mi] = {**messages[mi], "content": list(messages[mi]["content"])}
100
+ touched.add(mi)
101
+ messages[mi]["content"][pi] = part
102
+
103
+ older = parts[:-keep_full] if keep_full > 0 else parts
104
+ for mi, pi in older:
105
+ part = messages[mi]["content"][pi]
106
+ small = shrink_data_url(part["image_url"]["url"])
107
+ if small:
108
+ put(mi, pi, {**part, "image_url": {**part["image_url"], "url": small}})
109
+ report["shrunk"] += 1
110
+ out = {**body, "messages": messages} if touched else body
111
+ size = _size(out)
112
+ if size > budget:
113
+ report["before"] = size
114
+ for mi, pi in parts[:-1]: # the newest image is never dropped
115
+ if size <= budget:
116
+ break
117
+ part = messages[mi]["content"][pi]
118
+ was = len(json.dumps(part, separators=(",", ":"), ensure_ascii=False).encode("utf-8"))
119
+ note = {"type": "text", "text": "[An earlier screenshot was removed here to keep the request small enough to send.]"}
120
+ put(mi, pi, note)
121
+ size -= was - len(json.dumps(note, separators=(",", ":")).encode("utf-8"))
122
+ report["dropped"] += 1
123
+ out = {**body, "messages": messages}
124
+ report["after"] = size
125
+ return out, report
@@ -0,0 +1,226 @@
1
+ """THIS COMPUTER'S SECURITY CHECK (the owner, 2026-10-12: infostealers on a desktop — "can the helper check the desktop status
2
+ and recommend what to do" → "Yes"). witbitz-code runs as an ordinary program on the computer, so it can read what a web page
3
+ never can: Windows' antivirus, its settings, the firewall, disk encryption, updates, the account's rights. It READS without
4
+ an administrator; a Fix that changes a setting goes through Windows' own admin prompt (UAC), and nothing changes silently.
5
+ A setting Windows wants changed in its own app (memory integrity: driver checks; BitLocker: a recovery key) opens that page.
6
+
7
+ It is ADVICE, not proof: malware already running as this user can fake anything a program running as this user reads.
8
+ It finds weak settings before something gets in; it cannot say that nothing has. The page and Spaces say so.
9
+
10
+ Windows only for now (also from WSL, where it reads the Windows that hosts it). Other systems: {"supported": False}.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from typing import Callable
23
+
24
+ SECURITY_ROUTE = "/witbitz/security" # the connector answers it for the paired Spaces app (connector.py)
25
+
26
+ # ONE PowerShell read. Every part is guarded: a part Windows refuses reads as null, never as a failure of the whole check.
27
+ PROBE_PS = r"""
28
+ $ErrorActionPreference = 'SilentlyContinue'
29
+ $o = [ordered]@{}
30
+ $s = Get-MpComputerStatus
31
+ if ($s) { $o.defender = [ordered]@{ realtime = [bool]$s.RealTimeProtectionEnabled; behavior = [bool]$s.BehaviorMonitorEnabled;
32
+ tamper = [bool]$s.IsTamperProtected; sigAgeDays = [int]$s.AntivirusSignatureAge; mode = "$($s.AMRunningMode)" } }
33
+ $p = Get-MpPreference
34
+ if ($p) { $o.prefs = [ordered]@{ cloud = [int]$p.MAPSReporting; pua = [int]$p.PUAProtection; network = [int]$p.EnableNetworkProtection } }
35
+ $o.antivirus = @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName AntiVirusProduct | ForEach-Object {
36
+ [ordered]@{ name = "$($_.displayName)"; on = ((($_.productState -shr 12) -band 0xF) -eq 1) } })
37
+ $dg = Get-CimInstance -Namespace root/Microsoft/Windows/DeviceGuard -ClassName Win32_DeviceGuard
38
+ if ($dg) { $o.memoryIntegrity = [bool](@($dg.SecurityServicesRunning) -contains 2) }
39
+ $o.firewall = @(Get-NetFirewallProfile | ForEach-Object { [ordered]@{ name = "$($_.Name)"; on = [bool]$_.Enabled } })
40
+ try { $bl = (New-Object -ComObject Shell.Application).NameSpace("$env:SystemDrive\").Self.ExtendedProperty('System.Volume.BitLockerProtection'); if ($bl -ne $null) { $o.bitlocker = [int]$bl } } catch {}
41
+ try { $d = (New-Object -ComObject Microsoft.Update.AutoUpdate).Results.LastInstallationSuccessDate; if ($d) { $o.updateDaysAgo = [int]((Get-Date) - [datetime]$d).TotalDays } } catch {}
42
+ $uac = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
43
+ if ($uac) { $o.uac = [ordered]@{ on = ([int]$uac.EnableLUA -eq 1); prompt = [int]$uac.ConsentPromptBehaviorAdmin } }
44
+ $o.admin = [bool]((whoami /groups /fo csv | Out-String) -match 'S-1-5-32-544')
45
+ $o.os = "$((Get-CimInstance Win32_OperatingSystem).Caption)"
46
+ $o | ConvertTo-Json -Depth 4 -Compress
47
+ """
48
+
49
+ # The fixes. "ps" runs ELEVATED through Windows' admin prompt; "open" opens Windows' own page for a setting it checks itself.
50
+ FIXES = {
51
+ "pua": {"ps": "Set-MpPreference -PUAProtection Enabled"},
52
+ "network": {"ps": "Set-MpPreference -EnableNetworkProtection Enabled"},
53
+ "cloud": {"ps": "Set-MpPreference -MAPSReporting Advanced"},
54
+ "firewall": {"ps": "Set-NetFirewallProfile -All -Enabled True"},
55
+ "signatures": {"ps": "Update-MpSignature"},
56
+ "antivirus": {"open": "windowsdefender://threat"},
57
+ "memoryIntegrity": {"open": "windowsdefender://coreisolation"},
58
+ "tamper": {"open": "windowsdefender://threatsettings"},
59
+ "realtime": {"open": "windowsdefender://threatsettings"},
60
+ "bitlocker": {"open": "ms-settings:deviceencryption"},
61
+ "updates": {"open": "ms-settings:windowsupdate"},
62
+ }
63
+
64
+
65
+ def recommendations(s: dict | None) -> list[dict]:
66
+ """[{id, ok, title, why, fix?}] — what this computer has and what it should change, plainly. Pure: tests feed it readings."""
67
+ if not s:
68
+ return []
69
+ out: list[dict] = []
70
+
71
+ def add(id_: str, ok: bool | None, title: str, why: str = "") -> None:
72
+ if ok is None:
73
+ return # Windows did not say: say nothing rather than guess
74
+ item = {"id": id_, "ok": bool(ok), "title": title, "why": "" if ok else why}
75
+ if not ok and id_ in FIXES:
76
+ item["fix"] = "admin" if "ps" in FIXES[id_] else "open"
77
+ out.append(item)
78
+
79
+ other_av = [a for a in (s.get("antivirus") or []) if a.get("on") and "defender" not in (a.get("name") or "").lower()]
80
+ d = s.get("defender")
81
+ if other_av:
82
+ out.append({"id": "antivirus", "ok": True, "title": f"Antivirus: {other_av[0]['name']}", "why": ""})
83
+ elif d:
84
+ add("realtime", d.get("realtime") and d.get("behavior"), "Microsoft Defender real-time protection",
85
+ "Real-time protection is off — malware can run without being stopped. Turn it on in Windows Security.")
86
+ add("tamper", d.get("tamper"), "Tamper protection",
87
+ "Tamper protection is off — malware can turn Defender off before it acts.")
88
+ age = d.get("sigAgeDays")
89
+ add("signatures", None if age is None else age <= 3, "Virus definitions",
90
+ f"Defender's definitions are {age} days old — it will miss newer malware.")
91
+ p = s.get("prefs") or {}
92
+ add("cloud", None if p.get("cloud") is None else p["cloud"] >= 1, "Cloud protection",
93
+ "Cloud protection is off — brand-new malware is recognised much later.")
94
+ add("pua", None if p.get("pua") is None else p["pua"] == 1, "Blocking unwanted apps",
95
+ "Bundled and unwanted apps are only logged, not blocked. Stealers often arrive inside them.")
96
+ add("network", None if p.get("network") is None else p["network"] == 1, "Blocking malicious sites in every app",
97
+ "Network protection is off — only Edge blocks known malicious sites; other apps and malware can still reach them.")
98
+ else:
99
+ out.append({"id": "antivirus", "ok": False, "title": "Antivirus", "why": "No antivirus answered. Open Windows Security and turn Microsoft Defender on.",
100
+ "fix": "open"})
101
+ add("memoryIntegrity", s.get("memoryIntegrity"), "Memory integrity (Core isolation)",
102
+ "Memory integrity is off — malware that reaches the system's core is much harder to stop. Turning it on needs a restart.")
103
+ fw = s.get("firewall") or []
104
+ add("firewall", None if not fw else all(f.get("on") for f in fw), "Firewall",
105
+ "The firewall is off for: " + ", ".join(f["name"] for f in fw if not f.get("on")) + ".")
106
+ bl = s.get("bitlocker")
107
+ add("bitlocker", None if bl is None else bl in (1, 3, 5), "Disk encryption (BitLocker)",
108
+ "The disk is not encrypted — anyone with the laptop can read it. Turn on device encryption and keep its recovery key safe.")
109
+ days = s.get("updateDaysAgo")
110
+ add("updates", None if days is None else days <= 21, "Windows updates",
111
+ f"Windows last installed updates {days} days ago. Updates close the holes malware uses.")
112
+ uac = s.get("uac") or {}
113
+ add("uac", None if not uac else (uac.get("on") and uac.get("prompt") != 0), "Admin prompt (UAC)",
114
+ "UAC is off or never asks — any program you run gets full control of the computer.")
115
+ if s.get("admin"):
116
+ out.append({"id": "adminAccount", "ok": False, "title": "Daily account",
117
+ "why": "You use an administrator account every day. A standard account for daily use, with the admin account kept for installs, "
118
+ "stops much malware from taking over the whole computer. (Optional, but the strongest single step.)"})
119
+ return out
120
+
121
+
122
+ def summary(items: list[dict]) -> dict:
123
+ todo = [i for i in items if not i["ok"]]
124
+ return {"checked": len(items), "todo": len(todo), "titles": [i["title"] for i in todo]}
125
+
126
+
127
+ # ── running it ──────────────────────────────────────────────────────────────────────────────────────────────────────
128
+ def _in_wsl() -> bool:
129
+ try:
130
+ with open("/proc/version", encoding="utf-8") as f:
131
+ return "microsoft" in f.read().lower()
132
+ except OSError:
133
+ return False
134
+
135
+
136
+ def powershell() -> str | None:
137
+ """The PowerShell that reads Windows: native, or the host's from WSL. None off Windows."""
138
+ if sys.platform == "win32":
139
+ return shutil.which("powershell.exe") or shutil.which("powershell") or os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
140
+ if _in_wsl():
141
+ return shutil.which("powershell.exe") or ("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" if os.path.exists("/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe") else None)
142
+ return None
143
+
144
+
145
+ def _run_ps(ps: str, script: str, timeout: int = 60) -> subprocess.CompletedProcess:
146
+ flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
147
+ return subprocess.run([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], # noqa: S603
148
+ check=False, stdin=subprocess.DEVNULL, capture_output=True, text=True, errors="replace",
149
+ timeout=timeout, creationflags=flags, cwd="/mnt/c" if _in_wsl() and os.path.isdir("/mnt/c") else None)
150
+
151
+
152
+ def read_status(run: Callable[[str, str], subprocess.CompletedProcess] = _run_ps, ps: str | None = None) -> dict:
153
+ ps = ps or powershell()
154
+ if not ps:
155
+ return {"supported": False}
156
+ r = run(ps, PROBE_PS)
157
+ out = (r.stdout or "").strip().splitlines()
158
+ try:
159
+ return {"supported": True, **json.loads(out[-1] if out else "")}
160
+ except (ValueError, IndexError):
161
+ return {"supported": True, "error": "Windows did not answer the check" + (f" ({(r.stderr or '').strip()[:120]})" if r.stderr else "")}
162
+
163
+
164
+ def fix(item_id: str, run: Callable[[str, str], subprocess.CompletedProcess] = _run_ps, ps: str | None = None) -> dict:
165
+ """Apply one fix: an admin change through Windows' UAC prompt, or Windows' own page for it. → {ok, why?}"""
166
+ f = FIXES.get(item_id)
167
+ ps = ps or powershell()
168
+ if not f or not ps:
169
+ return {"ok": False, "why": "nothing here can change that"}
170
+ if "open" in f:
171
+ r = run(ps, f"Start-Process '{f['open']}'", 30)
172
+ return {"ok": r.returncode == 0, "opened": True, **({} if r.returncode == 0 else {"why": "Windows did not open the page"})}
173
+ # Elevated: Windows shows its admin prompt; -Wait so the check that follows reads the result. A No at the prompt fails Start-Process.
174
+ inner = f["ps"].replace("'", "''")
175
+ script = (f"try {{ $p = Start-Process powershell -Verb RunAs -Wait -PassThru -WindowStyle Hidden "
176
+ f"-ArgumentList '-NoProfile','-Command','{inner}'; exit $p.ExitCode }} catch {{ exit 1223 }}")
177
+ r = run(ps, script, 300)
178
+ if r.returncode == 1223:
179
+ return {"ok": False, "why": "the admin prompt was declined — nothing changed"}
180
+ return {"ok": r.returncode == 0, **({} if r.returncode == 0 else {"why": f"Windows refused it (code {r.returncode})"})}
181
+
182
+
183
+ class SecurityCheck:
184
+ """{supported, items, summary, at, os, error?} for the page; read at most once a minute, again right after a fix."""
185
+
186
+ TTL = 60
187
+
188
+ def __init__(self, *, read: Callable[[], dict] | None = None, apply: Callable[[str], dict] | None = None,
189
+ now: Callable[[], float] = time.monotonic) -> None:
190
+ self._read = read or (lambda: read_status()) # looked up at call time
191
+ self._apply = apply or (lambda i: fix(i))
192
+ self._now = now
193
+ self._cache: tuple[float, dict] | None = None
194
+ self._task: asyncio.Task | None = None
195
+
196
+ def status(self, fresh: bool = False) -> dict:
197
+ if not fresh and self._cache and self._now() - self._cache[0] < self.TTL:
198
+ return self._cache[1]
199
+ try:
200
+ raw = self._read()
201
+ except Exception as e: # noqa: BLE001 — a reading, never a failure of the page
202
+ raw = {"supported": True, "error": f"could not check ({str(e)[:80]})"}
203
+ if not raw.get("supported"):
204
+ out = {"supported": False}
205
+ else:
206
+ items = recommendations(raw if not raw.get("error") else None)
207
+ out = {"supported": True, "items": items, "summary": summary(items), "os": raw.get("os") or "",
208
+ "at": int(time.time()), **({"error": raw["error"]} if raw.get("error") else {})}
209
+ self._cache = (self._now(), out)
210
+ return out
211
+
212
+ def peek(self) -> dict:
213
+ """What the page shows NOW, never waiting on Windows (a read takes seconds): the last reading, and a fresh one started
214
+ in the background when it is stale. {"checking": True} before the first one lands."""
215
+ stale = not self._cache or self._now() - self._cache[0] >= self.TTL
216
+ if stale and not (self._task and not self._task.done()):
217
+ try:
218
+ self._task = asyncio.get_running_loop().create_task(asyncio.to_thread(self.status, True))
219
+ except RuntimeError:
220
+ pass
221
+ return self._cache[1] if self._cache else {"checking": True}
222
+
223
+ async def fix(self, item_id: str) -> dict:
224
+ r = await asyncio.to_thread(self._apply, item_id)
225
+ await asyncio.to_thread(self.status, True) # the card shows the result at once
226
+ return r
@@ -35,7 +35,7 @@ from importlib import resources
35
35
  from pathlib import Path
36
36
  from typing import Any, Awaitable, Callable
37
37
 
38
- from . import __version__, devicelink, link, net, ports, qr, uitools, uninstall, winbg, wizard, wslstart
38
+ from . import __version__, devicelink, link, net, ports, qr, seccheck, uitools, uninstall, winbg, wizard, wslstart
39
39
  from . import pair as pair_cmd
40
40
  from . import pairings as pp
41
41
 
@@ -127,10 +127,12 @@ class Manager:
127
127
  login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
128
128
  login_disable: Callable[[dict], bool] = wslstart.startup_disable,
129
129
  port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
130
- tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None) -> None:
130
+ tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None,
131
+ security: seccheck.SecurityCheck | None = None) -> None:
131
132
  self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
132
133
  self._load, self._keys_line = load_pairings, keys_line
133
134
  self.tools = tools or uitools.Tools()
135
+ self.security = security or seccheck.SecurityCheck()
134
136
  self._owners = port_owners or ports.port_owners
135
137
  self._stop_pid = stop_pid or uninstall.stop_process
136
138
  self._elsewhere_cache: tuple[float, dict | None] | None = None
@@ -214,7 +216,7 @@ class Manager:
214
216
  serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
215
217
  return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
216
218
  "login": self.login(), "busy": self.busy, "background": self.background,
217
- "tools": await asyncio.to_thread(self.tools.status), "log": list(self.log)[-14:]}
219
+ "tools": await asyncio.to_thread(self.tools.status), "security": self.security.peek(), "log": list(self.log)[-14:]}
218
220
 
219
221
  async def elsewhere(self, fresh: bool = False) -> dict | None:
220
222
  """{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
@@ -535,6 +537,7 @@ class Server:
535
537
  "/api/login": lambda: self.m.set_login(bool(data.get("on"))),
536
538
  "/api/quit": self.m.quit,
537
539
  "/api/tools/install": self.m.tools.install,
540
+ "/api/security/fix": lambda: self.m.security.fix(str(data.get("id") or "")),
538
541
  }
539
542
  act = actions.get(path)
540
543
  if not act:
@@ -51,3 +51,11 @@ footer { font-size: 13px; text-align: center; }
51
51
  .actions:not(:has(button:not([hidden]))) { display: none; }
52
52
  button.small { font-size: 13px; padding: 2px 10px; margin-left: 8px; }
53
53
  pre.cmd { background: var(--off-bg); color: var(--text); padding: 8px 10px; border-radius: 8px; }
54
+ /* the security check (seccheck.py) */
55
+ p.small, div.small { font-size: 12.5px; }
56
+ .sec-list { list-style: none; padding: 0; margin: 8px 0; }
57
+ .sec-list li { padding: 8px 0; border-top: 1px solid var(--line, #e5e7eb); }
58
+ .sec-list li:first-child { border-top: 0; }
59
+ .sec-list li.todo .sec-head span { font-weight: 600; }
60
+ .sec-list li.ok { opacity: .7; }
61
+ .sec-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
@@ -97,6 +97,7 @@ function render(s) {
97
97
  $('footerText').textContent = s.background ? 'runs in the background — Start menu → Witbitz Code opens this page' : 'keep its window open — closing it stops witbitz-code'
98
98
  show($('quit'), !!s.background)
99
99
  renderTools(s.tools, paired)
100
+ renderSecurity(s.security)
100
101
 
101
102
  $('log').textContent = (s.log || []).join('\n')
102
103
  }
@@ -161,6 +162,46 @@ function renderTools(t, paired) {
161
162
  $('toolsLog').textContent = lines.join('\n')
162
163
  }
163
164
 
165
+ // This computer's security check (seccheck.py): what Windows has, what to change, and a Fix for what can be fixed from here.
166
+ function renderSecurity(sec) {
167
+ const card = $('secCard')
168
+ if (!sec || sec.supported === false) return show(card, false)
169
+ show(card, true)
170
+ const items = sec.items || []
171
+ const todo = items.filter((i) => !i.ok)
172
+ $('secText').textContent = sec.checking ? 'Checking Windows…'
173
+ : sec.error ? sec.error
174
+ : todo.length ? `${todo.length} ${todo.length === 1 ? 'thing' : 'things'} to change` + (sec.os ? ` · ${sec.os}` : '')
175
+ : 'Everything checked here is set well ✓' + (sec.os ? ` · ${sec.os}` : '')
176
+ const list = $('secList')
177
+ const key = JSON.stringify(items) + '|' + !!(state && state.busy)
178
+ if (list.dataset.key === key) return
179
+ list.dataset.key = key
180
+ list.textContent = ''
181
+ for (const i of [...todo, ...items.filter((x) => x.ok)]) {
182
+ const li = document.createElement('li')
183
+ li.className = i.ok ? 'ok' : 'todo'
184
+ const head = document.createElement('div')
185
+ head.className = 'sec-head'
186
+ const t = document.createElement('span')
187
+ t.textContent = (i.ok ? '✓ ' : '! ') + i.title
188
+ head.append(t)
189
+ if (i.fix) {
190
+ const b = document.createElement('button')
191
+ b.type = 'button'
192
+ b.className = 'small'
193
+ b.textContent = i.fix === 'admin' ? 'Fix' : 'Open in Windows'
194
+ b.disabled = !!(state && state.busy)
195
+ b.onclick = () => act('/api/security/fix', { id: i.id },
196
+ i.fix === 'admin' ? `Change it now? Windows will ask for permission (an admin prompt) — say Yes there.` : '')
197
+ head.append(b)
198
+ }
199
+ li.append(head)
200
+ if (i.why) { const w = document.createElement('div'); w.className = 'muted small'; w.textContent = i.why; li.append(w) }
201
+ list.append(li)
202
+ }
203
+ }
204
+
164
205
  let quitting = false
165
206
  async function quit() {
166
207
  if (!window.confirm('Quit Witbitz Code? Code in Spaces will not reach this computer until you open Witbitz Code from the Start menu again.')) return
@@ -51,6 +51,14 @@
51
51
  <pre id="toolsLog" class="cmd" hidden></pre>
52
52
  </section>
53
53
 
54
+ <section class="card" id="secCard" hidden>
55
+ <h2>This computer's security</h2>
56
+ <p id="secText" class="muted"></p>
57
+ <ul id="secList" class="sec-list"></ul>
58
+ <p class="muted small">A check of Windows' settings, not a malware scan. It finds weak settings before something gets in;
59
+ malware already running here could hide from it.</p>
60
+ </section>
61
+
54
62
  <section class="card" id="loginCard" hidden>
55
63
  <label class="switch-row" for="login">
56
64
  <span>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.6.9
3
+ Version: 1.7.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
@@ -19,6 +19,7 @@ Requires-Dist: websockets>=13
19
19
  Requires-Dist: httpx>=0.25
20
20
  Requires-Dist: segno>=1.5
21
21
  Requires-Dist: tinfoil<0.15,>=0.14
22
+ Requires-Dist: pillow>=10
22
23
  Provides-Extra: test
23
24
  Requires-Dist: pytest>=8; extra == "test"
24
25
  Requires-Dist: pyflakes>=3; extra == "test"
@@ -21,6 +21,7 @@ src/witbitz_code/devicelink.py
21
21
  src/witbitz_code/dirs.py
22
22
  src/witbitz_code/folders.py
23
23
  src/witbitz_code/gateway.py
24
+ src/witbitz_code/images.py
24
25
  src/witbitz_code/keys.py
25
26
  src/witbitz_code/link.py
26
27
  src/witbitz_code/mkdir.py
@@ -37,6 +38,7 @@ src/witbitz_code/ports.py
37
38
  src/witbitz_code/qr.py
38
39
  src/witbitz_code/receipt.py
39
40
  src/witbitz_code/relay.py
41
+ src/witbitz_code/seccheck.py
40
42
  src/witbitz_code/seen.py
41
43
  src/witbitz_code/tinfoil_read.py
42
44
  src/witbitz_code/tools_catalog.json
@@ -77,6 +79,7 @@ tests/test_devicelink.py
77
79
  tests/test_dirs.py
78
80
  tests/test_files_and_compress.py
79
81
  tests/test_folders.py
82
+ tests/test_images.py
80
83
  tests/test_outputs.py
81
84
  tests/test_pair_flows.py
82
85
  tests/test_plugins.py
@@ -85,6 +88,7 @@ tests/test_ports.py
85
88
  tests/test_ports_win.py
86
89
  tests/test_registry.py
87
90
  tests/test_relay_codec.py
91
+ tests/test_seccheck.py
88
92
  tests/test_setup.py
89
93
  tests/test_tools_probe.py
90
94
  tests/test_toolsetup.py
@@ -3,6 +3,7 @@ websockets>=13
3
3
  httpx>=0.25
4
4
  segno>=1.5
5
5
  tinfoil<0.15,>=0.14
6
+ pillow>=10
6
7
 
7
8
  [test]
8
9
  pytest>=8