witbitz-code 1.6.10__tar.gz → 1.7.1__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.10/src/witbitz_code.egg-info → witbitz_code-1.7.1}/PKG-INFO +1 -1
  2. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/pyproject.toml +1 -1
  3. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/auto_runner.py +68 -2
  5. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/cli.py +2 -1
  6. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/connector.py +18 -4
  7. witbitz_code-1.7.1/src/witbitz_code/seccheck.py +226 -0
  8. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/ui.py +6 -3
  9. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/ui_static/app.css +8 -0
  10. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/ui_static/app.js +41 -0
  11. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/ui_static/index.html +8 -0
  12. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/uninstall.py +1 -1
  13. {witbitz_code-1.6.10 → witbitz_code-1.7.1/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  14. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  15. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_auto.py +111 -0
  16. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_connector_e2e.py +23 -0
  17. witbitz_code-1.7.1/tests/test_seccheck.py +143 -0
  18. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_uninstall.py +1 -1
  19. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/LICENSE +0 -0
  20. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/README.md +0 -0
  21. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/setup.cfg +0 -0
  22. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/__main__.py +0 -0
  23. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/_js.py +0 -0
  24. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/account.py +0 -0
  25. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/asks.py +0 -0
  26. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/attachments.py +0 -0
  27. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/auto.py +0 -0
  28. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/codekeys.py +0 -0
  29. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/codetools.py +0 -0
  30. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/compress.py +0 -0
  31. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/computers.py +0 -0
  32. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/confidential.py +0 -0
  33. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/confidential_models.json +0 -0
  34. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/devicelink.py +0 -0
  35. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/dirs.py +0 -0
  36. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/folders.py +0 -0
  37. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/gateway.py +0 -0
  38. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  39. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  40. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/images.py +0 -0
  41. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/keys.py +0 -0
  42. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/link.py +0 -0
  43. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/mkdir.py +0 -0
  44. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/models.py +0 -0
  45. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/net.py +0 -0
  46. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/offered_models.json +0 -0
  47. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  48. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  49. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  50. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  51. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/outputs.py +0 -0
  52. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/pair.py +0 -0
  53. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/pairings.py +0 -0
  54. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/payers.py +0 -0
  55. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/plugins.py +0 -0
  56. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/policy.py +0 -0
  57. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/ports.py +0 -0
  58. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/qr.py +0 -0
  59. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/receipt.py +0 -0
  60. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/relay.py +0 -0
  61. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/seen.py +0 -0
  62. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/tinfoil_read.py +0 -0
  63. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/tools_catalog.json +0 -0
  64. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/tools_probe.py +0 -0
  65. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/toolsetup.py +0 -0
  66. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/uitools.py +0 -0
  67. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/winagent.py +0 -0
  68. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/winbg.py +0 -0
  69. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/winconsole.py +0 -0
  70. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/wizard.py +0 -0
  71. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code/wslstart.py +0 -0
  72. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  73. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  74. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/requires.txt +0 -0
  75. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/src/witbitz_code.egg-info/top_level.txt +0 -0
  76. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_asks.py +0 -0
  77. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_attachments.py +0 -0
  78. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_cli.py +0 -0
  79. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_codekeys.py +0 -0
  80. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_codetools.py +0 -0
  81. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_confidential.py +0 -0
  82. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_devicelink.py +0 -0
  83. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_dirs.py +0 -0
  84. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_files_and_compress.py +0 -0
  85. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_folders.py +0 -0
  86. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_images.py +0 -0
  87. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_outputs.py +0 -0
  88. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_pair_flows.py +0 -0
  89. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_plugins.py +0 -0
  90. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_policy.py +0 -0
  91. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_ports.py +0 -0
  92. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_ports_win.py +0 -0
  93. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_registry.py +0 -0
  94. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_relay_codec.py +0 -0
  95. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_setup.py +0 -0
  96. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_tools_probe.py +0 -0
  97. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_toolsetup.py +0 -0
  98. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_ui.py +0 -0
  99. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_uitools.py +0 -0
  100. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_winagent.py +0 -0
  101. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_winbg.py +0 -0
  102. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_winconsole.py +0 -0
  103. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/tests/test_windows_files.py +0 -0
  104. {witbitz_code-1.6.10 → witbitz_code-1.7.1}/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.10
3
+ Version: 1.7.1
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.6.10"
7
+ version = "1.7.1"
8
8
  description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.6.10"
7
+ __version__ = "1.7.1"
@@ -22,6 +22,7 @@ import asyncio
22
22
  import math
23
23
  import os
24
24
  import re
25
+ import secrets
25
26
  import stat
26
27
  import time
27
28
  from collections.abc import Awaitable, Callable
@@ -42,6 +43,15 @@ MAX_USER_MESSAGES = 6
42
43
  MAX_DETAIL = 300
43
44
  PARENT_TTL_MS = 10 * 60_000
44
45
  AGENTS_TTL_MS = 60_000
46
+ # THE REVIEWER'S MODEL (the owner, 2026-10-13: "I am not sure if the auto mode classifier is working correctly"). Reviewed as
47
+ # an OpenCode session with the session's own model, a review carried OpenCode's whole agent prompt (~35k chars) ahead of ours
48
+ # and the model's full reasoning: DeepSeek V4.1 Flash went from a 5 s median to 30 s timeouts, and every ask fell to the
49
+ # person. Where the computer runs the model proxy (`model_url`), the policy is now asked DIRECTLY, of a fixed fast confidential
50
+ # model, with a backup. Measured on 8 cases: GPT-OSS 120B 8/8 in 2.5–3.5 s, DeepSeek V4.1 Flash 8/8 in 5–16 s. Any failure is
51
+ # still the person's. Held equal to tools/code-auto-runner.mjs.
52
+ REVIEW_MODELS = ("openai/gpt-oss-120b", "deepseek/deepseek-v4.1-flash")
53
+ REVIEW_TIMEOUT_MS = 45_000 # the whole review, backup included
54
+ FIRST_TRY_MS = 20_000 # the first model's share, so a hung one leaves the backup time to answer
45
55
  MAX_DEPTH = 4 # parent links followed looking for the session in Auto
46
56
  REFUSAL_SUFFIX = "(Witbitz Auto mode refused this — try a narrower or safer step.)"
47
57
  _UNKNOWN = object() # OpenCode could not say who a session's parent is (not cached)
@@ -107,11 +117,15 @@ def scratch_on_disk(paths: list[str], directory: str = SCRATCH_DIR) -> bool:
107
117
 
108
118
  class AutoRunner:
109
119
  def __init__(self, *, base: str, auth: Callable[[], dict], client: httpx.AsyncClient, state_path: Path | None,
110
- log_path: Path | None, poll_ms: float = 1000, review_timeout_ms: float = 30_000, home: str | None = None,
120
+ log_path: Path | None, poll_ms: float = 1000, review_timeout_ms: float = REVIEW_TIMEOUT_MS, home: str | None = None,
111
121
  on_verdict: Callable[[dict], Any] = lambda v: None, log: Callable[[str], Any] = lambda m: None,
112
122
  now: Callable[[], int] = _now_ms, scratch_check: Callable[[list[str]], bool] = scratch_on_disk,
113
123
  list_asks: Callable[[str], Awaitable[list | None]] | None = None,
114
- on_review: Callable[[str, bool], Any] | None = None) -> None:
124
+ on_review: Callable[[str, bool], Any] | None = None, model_url: str = "",
125
+ review_models: tuple[str, ...] | list[str] = REVIEW_MODELS, first_try_ms: float = FIRST_TRY_MS) -> None:
126
+ self._model_url = re.sub(r"/+\Z", "", model_url or "")
127
+ self._review_models = list(review_models)
128
+ self._first_try_ms = first_try_ms
115
129
  self._root = re.sub(r"/+\Z", "", base or "")
116
130
  # The review session's model calls are billed to the account that turned Auto on (payers.py): told when the
117
131
  # review opens and closes, since nothing from the Spaces app ever posts into it.
@@ -291,8 +305,60 @@ class AutoRunner:
291
305
  return []
292
306
  return allowed_folders(data.get("permission")) if ok and isinstance(data, dict) else []
293
307
 
308
+ async def _ask_model(self, m: str, prompt: dict, rid: str) -> tuple[dict | None, str]:
309
+ body = {"model": m, "messages": [{"role": "system", "content": prompt["system"]}, {"role": "user", "content": prompt["text"]}],
310
+ "max_tokens": 1500, "reasoning_effort": "low", "stream": False}
311
+ r = await self._client.post(f"{self._model_url}/chat/completions", headers={"content-type": "application/json", "x-session-id": rid},
312
+ content=_js.utf8(_js.stringify(body)))
313
+ try:
314
+ data = _js.parse(r.text)
315
+ except ValueError:
316
+ data = None
317
+ choices = data.get("choices") if isinstance(data, dict) and isinstance(data.get("choices"), list) else []
318
+ msg = choices[0].get("message") if choices and isinstance(choices[0], dict) and isinstance(choices[0].get("message"), dict) else {}
319
+ text = msg.get("content") if 200 <= r.status_code < 300 and isinstance(msg.get("content"), str) else ""
320
+ verdict = parse_verdict(text) if text else None
321
+ if verdict:
322
+ return verdict, ""
323
+ return None, f"answered {r.status_code}" if not 200 <= r.status_code < 300 else "no verdict in its answer" if text else "no answer"
324
+
325
+ async def _review_direct(self, prompt: dict) -> tuple[dict | None, str, str]:
326
+ """Ask the policy of the review models in turn, through the computer's model proxy (billed like the session: on_review)."""
327
+ rid = "ses_review_" + secrets.token_urlsafe(9)
328
+ until = time.monotonic() + self._review_timeout_ms / 1000
329
+ notes: list[str] = []
330
+ if self._on_review:
331
+ self._on_review(rid, True)
332
+ try:
333
+ for i, m in enumerate(self._review_models):
334
+ left = until - time.monotonic()
335
+ if left <= 0:
336
+ break
337
+ limit = left if i == len(self._review_models) - 1 else min(left, self._first_try_ms / 1000)
338
+ try:
339
+ verdict, why = await asyncio.wait_for(self._ask_model(m, prompt, rid), limit)
340
+ except asyncio.TimeoutError:
341
+ verdict, why = None, "did not answer in time"
342
+ except asyncio.CancelledError:
343
+ raise
344
+ except Exception:
345
+ verdict, why = None, "could not be reached"
346
+ if verdict:
347
+ return verdict, f"trustedrouter/{m}", ""
348
+ notes.append(f"{m}: {why}")
349
+ finally:
350
+ if self._on_review:
351
+ self._on_review(rid, False)
352
+ first = self._review_models[0] if self._review_models else ""
353
+ secs = math.floor(self._review_timeout_ms / 1000 + 0.5) or 1
354
+ return None, f"trustedrouter/{first}", f"the reviewer did not answer ({'; '.join(notes) or f'nothing within {secs} s'})"
355
+
294
356
  async def _review(self, req: dict, directory: str, owner: str | None = None, folders: list[str] | None = None) -> tuple[dict | None, str, str]:
295
357
  model, user_messages, tools = await self._session_context(owner or req["sessionID"], directory)
358
+ if self._model_url and self._review_models:
359
+ call = req.get("tool") if isinstance(req.get("tool"), dict) else {}
360
+ tool = tools.get(call["callID"], "") if isinstance(call.get("callID"), str) else ""
361
+ return await self._review_direct(reviewer_prompt(req, directory, user_messages, tool, folders or []))
296
362
  if not model:
297
363
  return None, "", "the session has no model to review with yet"
298
364
  label = f"{_js.js_string(model['providerID'])}/{_js.js_string(model['modelID'])}"
@@ -261,7 +261,8 @@ async def serve(args: list[str], *, stop: Any = None, live_out: dict | None = No
261
261
  return 0
262
262
  if child is not None:
263
263
  _err("witbitz-code: model calls are paid by each account's own coding keys; confidential models are enforced (min_privacy + verified receipts)")
264
- connector = await start_connector(pairings, payers=payers)
264
+ # Auto mode's reviewer asks the policy of the model proxy directly (auto_runner.REVIEW_MODELS), billed like the session.
265
+ connector = await start_connector(pairings, payers=payers, model_url=f"http://127.0.0.1:{proxy.port}/v1" if proxy else "")
265
266
  live["status"] = asyncio.ensure_future(_check_keys_every(live_keys, keys_http))
266
267
  live["connector"] = connector
267
268
  if live_out is not None:
@@ -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
@@ -155,8 +157,10 @@ class PairingServer:
155
157
  max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
156
158
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
157
159
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
158
- notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> None:
160
+ notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
161
+ model_url: str = "") -> None:
159
162
  self.pairing = pairing
163
+ self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
160
164
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
161
165
  self._payers = payers
162
166
  self._cid = _js.js_string(pairing.get("computerId") or "")
@@ -209,7 +213,8 @@ class PairingServer:
209
213
  self.auto = AutoRunner(base=self.base, auth=self._auth, client=client, poll_ms=auto_poll_ms, log=log,
210
214
  state_path=auto_dir / f"auto-{safe_id}.json", log_path=auto_dir / "auto-log.jsonl",
211
215
  on_verdict=lambda v: self.peer.send({"t": "autoverdict", **v, "ts": int(time.time() * 1000)}),
212
- list_asks=self._pending_asks, on_review=self._own_review)
216
+ list_asks=self._pending_asks, on_review=self._own_review,
217
+ model_url=model_url) # the reviewer asks the model proxy directly (auto_runner.REVIEW_MODELS)
213
218
 
214
219
  # ── lifecycle ─────────────────────────────────────────────────────────────────────────────────────────────────────
215
220
  async def start(self) -> None:
@@ -407,6 +412,14 @@ class PairingServer:
407
412
  st, b = list_dirs(path=q.get("path", ""))
408
413
  await reply(st, b)
409
414
  return
415
+ # "THIS COMPUTER'S SECURITY" (seccheck.py): the same check as the witbitz-code page, for the account's own Spaces app —
416
+ # read here, sealed through the relay like every answer, never written to the account's stores. Python only (the Node
417
+ # download answers 404 and the page says the check is not available there).
418
+ if bare == SECURITY_ROUTE and m.get("m") == "GET":
419
+ if self._security is None:
420
+ self._security = seccheck.SecurityCheck()
421
+ await reply(200, await asyncio.to_thread(self._security.status))
422
+ return
410
423
  # "NEW FOLDER" (mkdir.py = tools/code-mkdir.mjs): one clean leaf under the folder the picker shows. Answered HERE.
411
424
  if bare == MKDIR_ROUTE and m.get("m") == "POST":
412
425
  q = dict(urllib.parse.parse_qsl(query, keep_blank_values=True))
@@ -869,7 +882,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
869
882
  max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
870
883
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
871
884
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
872
- notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> Connector:
885
+ notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
886
+ model_url: str = "") -> Connector:
873
887
  """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
874
888
  request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
875
889
  client = client or local_client()
@@ -884,7 +898,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
884
898
  s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
885
899
  max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
886
900
  attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
887
- notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers)
901
+ notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url)
888
902
  await s.start()
889
903
  servers.append(s)
890
904
  return Connector(servers, client, payers)
@@ -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>
@@ -302,7 +302,7 @@ def remove_opencode_program(info: dict) -> dict:
302
302
  return {"ok": False, "why": f"{info['path']} is still there", "said": said} if os.path.exists(info["path"]) else {"ok": True, "said": said}
303
303
 
304
304
 
305
- WINDOWS_REINSTALL = "irm https://app.witbitz.chat/code.ps1 | iex"
305
+ WINDOWS_REINSTALL = "the install lines at https://witbitz.chat/code (or Code in your Spaces app)" # app-neutral: each app serves its own code.ps1
306
306
 
307
307
 
308
308
  def windows_installer_root() -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.6.10
3
+ Version: 1.7.1
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
@@ -38,6 +38,7 @@ src/witbitz_code/ports.py
38
38
  src/witbitz_code/qr.py
39
39
  src/witbitz_code/receipt.py
40
40
  src/witbitz_code/relay.py
41
+ src/witbitz_code/seccheck.py
41
42
  src/witbitz_code/seen.py
42
43
  src/witbitz_code/tinfoil_read.py
43
44
  src/witbitz_code/tools_catalog.json
@@ -87,6 +88,7 @@ tests/test_ports.py
87
88
  tests/test_ports_win.py
88
89
  tests/test_registry.py
89
90
  tests/test_relay_codec.py
91
+ tests/test_seccheck.py
90
92
  tests/test_setup.py
91
93
  tests/test_tools_probe.py
92
94
  tests/test_toolsetup.py