witbitz-code 1.7.0__tar.gz → 1.7.2__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 (106) hide show
  1. {witbitz_code-1.7.0/src/witbitz_code.egg-info → witbitz_code-1.7.2}/PKG-INFO +1 -1
  2. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/pyproject.toml +1 -1
  3. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/auto_runner.py +68 -2
  5. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/cli.py +2 -1
  6. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/connector.py +67 -6
  7. witbitz_code-1.7.2/src/witbitz_code/remind.py +89 -0
  8. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/uninstall.py +1 -1
  9. {witbitz_code-1.7.0 → witbitz_code-1.7.2/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  10. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  11. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_auto.py +112 -1
  12. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_connector_e2e.py +1 -1
  13. witbitz_code-1.7.2/tests/test_remind.py +133 -0
  14. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_uninstall.py +1 -1
  15. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/LICENSE +0 -0
  16. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/README.md +0 -0
  17. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/setup.cfg +0 -0
  18. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/__main__.py +0 -0
  19. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/_js.py +0 -0
  20. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/account.py +0 -0
  21. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/asks.py +0 -0
  22. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/attachments.py +0 -0
  23. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/auto.py +0 -0
  24. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/codekeys.py +0 -0
  25. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/codetools.py +0 -0
  26. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/compress.py +0 -0
  27. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/computers.py +0 -0
  28. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/confidential.py +0 -0
  29. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/confidential_models.json +0 -0
  30. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/devicelink.py +0 -0
  31. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/dirs.py +0 -0
  32. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/folders.py +0 -0
  33. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/gateway.py +0 -0
  34. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  35. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  36. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/images.py +0 -0
  37. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/keys.py +0 -0
  38. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/link.py +0 -0
  39. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/mkdir.py +0 -0
  40. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/models.py +0 -0
  41. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/net.py +0 -0
  42. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/offered_models.json +0 -0
  43. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  44. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  45. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  46. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  47. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/outputs.py +0 -0
  48. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/pair.py +0 -0
  49. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/pairings.py +0 -0
  50. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/payers.py +0 -0
  51. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/plugins.py +0 -0
  52. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/policy.py +0 -0
  53. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/ports.py +0 -0
  54. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/qr.py +0 -0
  55. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/receipt.py +0 -0
  56. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/relay.py +0 -0
  57. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/seccheck.py +0 -0
  58. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/seen.py +0 -0
  59. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/tinfoil_read.py +0 -0
  60. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/tools_catalog.json +0 -0
  61. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/tools_probe.py +0 -0
  62. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/toolsetup.py +0 -0
  63. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/ui.py +0 -0
  64. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/ui_static/app.css +0 -0
  65. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/ui_static/app.js +0 -0
  66. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/ui_static/index.html +0 -0
  67. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/uitools.py +0 -0
  68. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/winagent.py +0 -0
  69. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/winbg.py +0 -0
  70. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/winconsole.py +0 -0
  71. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/wizard.py +0 -0
  72. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code/wslstart.py +0 -0
  73. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  74. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  75. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/requires.txt +0 -0
  76. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/top_level.txt +0 -0
  77. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_asks.py +0 -0
  78. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_attachments.py +0 -0
  79. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_cli.py +0 -0
  80. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_codekeys.py +0 -0
  81. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_codetools.py +0 -0
  82. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_confidential.py +0 -0
  83. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_devicelink.py +0 -0
  84. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_dirs.py +0 -0
  85. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_files_and_compress.py +0 -0
  86. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_folders.py +0 -0
  87. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_images.py +0 -0
  88. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_outputs.py +0 -0
  89. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_pair_flows.py +0 -0
  90. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_plugins.py +0 -0
  91. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_policy.py +0 -0
  92. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_ports.py +0 -0
  93. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_ports_win.py +0 -0
  94. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_registry.py +0 -0
  95. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_relay_codec.py +0 -0
  96. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_seccheck.py +0 -0
  97. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_setup.py +0 -0
  98. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_tools_probe.py +0 -0
  99. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_toolsetup.py +0 -0
  100. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_ui.py +0 -0
  101. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_uitools.py +0 -0
  102. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_winagent.py +0 -0
  103. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_winbg.py +0 -0
  104. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_winconsole.py +0 -0
  105. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_windows_files.py +0 -0
  106. {witbitz_code-1.7.0 → witbitz_code-1.7.2}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.0
3
+ Version: 1.7.2
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.7.0"
7
+ version = "1.7.2"
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.7.0"
7
+ __version__ = "1.7.2"
@@ -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:
@@ -35,7 +35,8 @@ from .asks import ASK_FRAME, AskBook
35
35
  from .folders import FOLDER_ROUTE, folder_route
36
36
  from .attachments import (ATTACHMENT_ROUTE, MAX_FILE_BYTES, attachment_rule, default_root, prune_attachments,
37
37
  remove_session_attachments, serve_attachment, stage_message_body)
38
- from .auto_runner import AutoRunner
38
+ from .auto_runner import AutoRunner, detail_of
39
+ from .remind import ReminderClock, seal as seal_remind, valid as valid_remind # "waiting for your approval" (docs/approval-reminders.md)
39
40
  from .outputs import OUTPUT_ROUTE, serve_output
40
41
  from .plugins import notes_plugin_path as default_notes_plugin, notes_root as default_notes_root, notes_rules
41
42
  from .payers import SessionPayers
@@ -53,6 +54,7 @@ REQUEST_TIMEOUT_MS = 30_000
53
54
  HELLO_EVERY_S = 20.0 # the page counts the computer online while a hello arrived in the last 30 s
54
55
  SUB_TTL_S = 75.0 # a page re-subscribes every 30 s; a client that stops renewing is dropped
55
56
  SWEEP_EVERY_S = 15.0
57
+ REMIND_EVERY_S = 15.0 # how often waiting approvals are checked for a due tickle (remind.py)
56
58
  ASKS_RECONCILE_MS = 30_000 # how often recorded asks are checked against which sessions still run (asks.py)
57
59
  MAX_RESPONSE = 30 * 1024 * 1024 # bytes; below the page's 32 MiB reassembly cap: refuse while reading, not by a timeout
58
60
  START_HINT = "witbitz-code serve"
@@ -157,7 +159,8 @@ class PairingServer:
157
159
  max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
158
160
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
159
161
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
160
- notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> None:
162
+ notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
163
+ model_url: str = "", remind_times: dict | None = None) -> None:
161
164
  self.pairing = pairing
162
165
  self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
163
166
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
@@ -169,6 +172,10 @@ class PairingServer:
169
172
  # WHAT IS WAITING (asks.py): OpenCode's GET /permission breaks for a whole folder on one malformed ask and keeps a
170
173
  # stopped turn's asks — every folder's asks are followed on /global/event, for Auto and for the page's card recovery.
171
174
  self._asks = AskBook()
175
+ rt = remind_times or {} # tests: {first_s, second_s, every_s, allow_http}
176
+ self._remind_rt = rt
177
+ self._remind = ReminderClock(**{k: rt[k] for k in ("first_s", "second_s") if rt.get(k)}) # who to tickle when an approval waits (remind.py)
178
+ self._remind_parent: dict[str, str] = {} # a subagent's session → its parent's
172
179
  self._asks_live = False
173
180
  self._asks_epoch = 0 # one per connection of the stream
174
181
  self._asks_complete: dict[str, int] = {} # directory → the epoch its whole list was read in: the book knows every ask since
@@ -212,11 +219,13 @@ class PairingServer:
212
219
  self.auto = AutoRunner(base=self.base, auth=self._auth, client=client, poll_ms=auto_poll_ms, log=log,
213
220
  state_path=auto_dir / f"auto-{safe_id}.json", log_path=auto_dir / "auto-log.jsonl",
214
221
  on_verdict=lambda v: self.peer.send({"t": "autoverdict", **v, "ts": int(time.time() * 1000)}),
215
- list_asks=self._pending_asks, on_review=self._own_review)
222
+ list_asks=self._pending_asks, on_review=self._own_review,
223
+ model_url=model_url) # the reviewer asks the model proxy directly (auto_runner.REVIEW_MODELS)
216
224
 
217
225
  # ── lifecycle ─────────────────────────────────────────────────────────────────────────────────────────────────────
218
226
  async def start(self) -> None:
219
227
  self._spawn(self._every(SWEEP_EVERY_S, self._sweep))
228
+ self._spawn(self._every(self._remind_rt.get("every_s") or REMIND_EVERY_S, lambda: self._spawn(self._remind_tick())))
220
229
  self._spawn(self._every(HELLO_EVERY_S, lambda: self.hello() if self.peer.peers >= 2 else None))
221
230
  self._spawn(self._watch_asks())
222
231
  self._spawn(self._every(self._asks_reconcile_s, lambda: self._spawn(self._reconcile_asks()) if self._asks_live else None))
@@ -286,7 +295,7 @@ class PairingServer:
286
295
  return None # no hello before this socket has its nonce
287
296
  # caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
288
297
  return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
289
- "k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders"], "auto": self.auto.sessions()})
298
+ "k": self.nonce, "ts": int(time.time() * 1000), "caps": ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], "auto": self.auto.sessions()})
290
299
 
291
300
  def _auth(self) -> dict:
292
301
  pw = self.pairing.get("password") or read_env_password(Path(self.pairing["envFile"]) if self.pairing.get("envFile") else None)
@@ -315,6 +324,11 @@ class PairingServer:
315
324
  # page that can switch Auto could already answer the same asks itself.
316
325
  if current and self.auto.set_auto(m.get("sid"), m.get("dir"), _js.truthy(m.get("on"))):
317
326
  self.hello()
327
+ elif t == "remind":
328
+ # "Waiting for your approval" (remind.py): the device that sent a prompt, for the asks it leads to. Nonce-checked.
329
+ e = valid_remind(m, allow_http=bool(self._remind_rt.get("allow_http"))) if current else None
330
+ if e:
331
+ self._remind.note(m["sid"], e)
318
332
  elif t == "tools":
319
333
  # Which suggested tools this computer has (spaces/public/codeTools.js) — a PATH lookup, nonce-checked like a request.
320
334
  if current:
@@ -485,6 +499,52 @@ class PairingServer:
485
499
  if paid:
486
500
  self._payers.end(paid.group(1))
487
501
 
502
+ async def _remind_owner(self, sid: str, directory: str) -> str:
503
+ """The session a page named for reminders: this one, or the nearest parent (a subagent's ask)."""
504
+ cur = sid
505
+ for _ in range(4):
506
+ if self._remind.who(cur):
507
+ return cur
508
+ parent = self._remind_parent.get(cur)
509
+ if parent is None:
510
+ try:
511
+ st, text = await self._opencode("GET", f"/session/{urllib.parse.quote(cur, safe=_URI_SAFE)}", directory)
512
+ data = _js.parse(text) if 200 <= st < 300 else None
513
+ except asyncio.CancelledError:
514
+ raise
515
+ except Exception:
516
+ return ""
517
+ parent = data.get("parentID") if isinstance(data, dict) and isinstance(data.get("parentID"), str) else ""
518
+ self._remind_parent[cur] = parent
519
+ if len(self._remind_parent) > 2000:
520
+ del self._remind_parent[next(iter(self._remind_parent))]
521
+ if not parent:
522
+ return ""
523
+ cur = parent
524
+ return ""
525
+
526
+ async def _remind_tick(self) -> None:
527
+ """Each waiting approval: a tickle at one minute and a reminder at ten, to the device whose prompt led to it."""
528
+ if not self._asks_live:
529
+ return
530
+ for directory in self._asks.directories():
531
+ for ask, stage in self._remind.due(self._asks.list(directory)):
532
+ self._remind.sent(ask["id"], stage)
533
+ owner = await self._remind_owner(ask["sessionID"], directory)
534
+ e = self._remind.who(owner) if owner else None
535
+ if not e:
536
+ continue
537
+ n = seal_remind(e["key"], {"where": self.name, "what": detail_of(ask) or str(ask.get("permission") or ""),
538
+ "computer": self.pairing.get("computerId") or "", "session": owner})
539
+ try:
540
+ r = await self._client.post(e["url"], json={"op": "coderemind", "grant": e["grant"], "n": n}, timeout=15)
541
+ if r.status_code == 410:
542
+ self._remind.note(owner, {}) # that device's address is gone: nothing more to it
543
+ except asyncio.CancelledError:
544
+ raise
545
+ except Exception:
546
+ pass # offline: the next stage tries again
547
+
488
548
  def _own_review(self, session: str, start: bool) -> None:
489
549
  if not self._payers:
490
550
  return
@@ -880,7 +940,8 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
880
940
  max_response_bytes: int = MAX_RESPONSE, auto_dir: Path | None = None, auto_poll_ms: float = 1000,
881
941
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
882
942
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
883
- notes_plugin_path: Path | None = None, payers: SessionPayers | None = None) -> Connector:
943
+ notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
944
+ model_url: str = "", remind_times: dict | None = None) -> Connector:
884
945
  """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
885
946
  request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
886
947
  client = client or local_client()
@@ -895,7 +956,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
895
956
  s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
896
957
  max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
897
958
  attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
898
- notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers)
959
+ notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times)
899
960
  await s.start()
900
961
  servers.append(s)
901
962
  return Connector(servers, client, payers)
@@ -0,0 +1,89 @@
1
+ """"CODE IS WAITING FOR YOUR APPROVAL" — tools/code-remind.mjs, in Python (docs/approval-reminders.md).
2
+
3
+ The device that sends a prompt hands this computer {t:'remind', sid, grant, key, url} with it: its push address, signed by
4
+ the server (the grant), and a key of its own. When an approval in that session (or a subagent's under it) is still waiting
5
+ a minute later, and again at ten, the computer seals what is waiting under the device's key and asks the server to push it
6
+ to the grant's address (op `coderemind`). The server checks its own signature and a rate, and cannot read the text; the
7
+ device's service worker opens it (spaces/public/sw-notif-core.js wbxCodeRemindOpen) and a tap opens Code at the session.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import json
14
+ import os
15
+ import re
16
+ import time
17
+ from collections.abc import Callable
18
+ from typing import Any
19
+
20
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21
+
22
+ FIRST_S = 60 # the first tickle — an approval nobody has answered for a minute
23
+ SECOND_S = 600 # and the reminder at ten
24
+ MAX_SESSIONS = 200
25
+ AAD = b"witbitz-code-remind"
26
+ GRANT = re.compile(r"^[A-Za-z0-9_-]{20,2400}\.[A-Za-z0-9_-]{20,100}$")
27
+ KEY = re.compile(r"^[A-Za-z0-9_-]{43}$")
28
+
29
+
30
+ def _b64u_dec(s: str) -> bytes:
31
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
32
+
33
+
34
+ def seal(key_b64u: str, obj: dict) -> str:
35
+ """AES-256-GCM under the device's key: base64url(iv | ciphertext+tag), what wbxCodeRemindOpen opens."""
36
+ iv = os.urandom(12)
37
+ ct = AESGCM(_b64u_dec(key_b64u)).encrypt(iv, json.dumps(obj, separators=(",", ":")).encode("utf-8"), AAD)
38
+ return base64.urlsafe_b64encode(iv + ct).decode("ascii").rstrip("=")
39
+
40
+
41
+ def valid(m: Any, allow_http: bool = False) -> dict | None:
42
+ """A page's {t:'remind'} → the entry to keep, or None."""
43
+ if not isinstance(m, dict):
44
+ return None
45
+ sid, grant, key, url = m.get("sid"), m.get("grant"), m.get("key"), m.get("url")
46
+ if not (isinstance(sid, str) and 0 < len(sid) <= 120 and isinstance(grant, str) and GRANT.match(grant)
47
+ and isinstance(key, str) and KEY.match(key) and isinstance(url, str) and re.match(r"^https?://[^\s/]+(/[^\s]*)?$" if allow_http else r"^https://[^\s/]+(/[^\s]*)?$", url) and len(url) <= 300):
48
+ return None
49
+ return {"grant": grant, "key": key, "url": url}
50
+
51
+
52
+ class ReminderClock:
53
+ """Which device each session reminds, and which asks have had which tickle. `now` in seconds."""
54
+
55
+ def __init__(self, now: Callable[[], float] = time.monotonic, first_s: float = FIRST_S, second_s: float = SECOND_S) -> None:
56
+ self._now = now
57
+ self._first, self._second = first_s, second_s
58
+ self._by_session: dict[str, dict] = {}
59
+ self._seen: dict[str, dict] = {} # ask id → {at, sent: 0|1|2}
60
+
61
+ def note(self, sid: str, entry: dict) -> None:
62
+ self._by_session.pop(sid, None)
63
+ self._by_session[sid] = entry
64
+ while len(self._by_session) > MAX_SESSIONS:
65
+ del self._by_session[next(iter(self._by_session))]
66
+
67
+ def who(self, sid: str) -> dict | None:
68
+ return self._by_session.get(sid)
69
+
70
+ def due(self, asks: list[dict]) -> list[tuple[dict, int]]:
71
+ """Of the asks waiting now, the ones whose next tickle is due → [(ask, stage)]; forgets asks no longer waiting."""
72
+ t = self._now()
73
+ live = {a["id"] for a in asks if isinstance(a, dict) and isinstance(a.get("id"), str)}
74
+ for k in [k for k in self._seen if k not in live]:
75
+ del self._seen[k]
76
+ out = []
77
+ for a in asks:
78
+ if not isinstance(a, dict) or not isinstance(a.get("id"), str):
79
+ continue
80
+ s = self._seen.setdefault(a["id"], {"at": t, "sent": 0})
81
+ age = t - s["at"]
82
+ stage = 2 if age >= self._second else 1 if age >= self._first else 0
83
+ if stage > s["sent"]:
84
+ out.append((a, stage))
85
+ return out
86
+
87
+ def sent(self, ask_id: str, stage: int) -> None:
88
+ if ask_id in self._seen:
89
+ self._seen[ask_id]["sent"] = max(self._seen[ask_id]["sent"], stage)
@@ -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.7.0
3
+ Version: 1.7.2
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/remind.py
41
42
  src/witbitz_code/seccheck.py
42
43
  src/witbitz_code/seen.py
43
44
  src/witbitz_code/tinfoil_read.py
@@ -88,6 +89,7 @@ tests/test_ports.py
88
89
  tests/test_ports_win.py
89
90
  tests/test_registry.py
90
91
  tests/test_relay_codec.py
92
+ tests/test_remind.py
91
93
  tests/test_seccheck.py
92
94
  tests/test_setup.py
93
95
  tests/test_tools_probe.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"]
602
+ assert client.hellos()[-1]["caps"] == ["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"]
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)
@@ -700,3 +700,114 @@ def test_auto_switches_on_for_a_network_share(tmp_path):
700
700
  async def body(oc, runner, verdicts, make, client):
701
701
  assert runner.set_auto("ses_main", _UNC, True)
702
702
  with_runner(tmp_path, body)
703
+
704
+
705
+ # ── THE DIRECT REVIEWER (2026-10-13; = tools/code-auto-runner.test.mjs): with the computer's model proxy, the policy is asked
706
+ # of a fixed confidential model, then a backup — never through an OpenCode session.
707
+ ALLOW = '{"decision":"allow","severity":10,"rule":"allow:tests","reason":"the user asked for tests"}'
708
+
709
+
710
+ class FakeProxy:
711
+ def __init__(self, answers: dict) -> None:
712
+ self.seen: list[dict] = []
713
+ outer = self
714
+
715
+ class H(BaseHTTPRequestHandler):
716
+ def log_message(self, *a: Any) -> None:
717
+ pass
718
+
719
+ def do_POST(self) -> None:
720
+ b = json.loads(self.rfile.read(int(self.headers.get("content-length") or 0)))
721
+ outer.seen.append({"path": self.path, "sid": self.headers.get("x-session-id"), "body": b})
722
+ a = answers.get(b["model"], {"status": 404})
723
+ if a.get("delay"):
724
+ time.sleep(a["delay"])
725
+ data = json.dumps({"choices": [{"message": {"role": "assistant", "content": a["text"]}}]} if "text" in a
726
+ else {"error": {"message": "provider error"}}).encode()
727
+ try:
728
+ self.send_response(a.get("status", 200))
729
+ self.send_header("content-type", "application/json")
730
+ self.send_header("content-length", str(len(data)))
731
+ self.end_headers()
732
+ self.wfile.write(data)
733
+ except OSError:
734
+ pass
735
+
736
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
737
+ self.httpd.daemon_threads = True
738
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
739
+ self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/v1"
740
+
741
+ def close(self) -> None:
742
+ self.httpd.shutdown()
743
+ self.httpd.server_close()
744
+
745
+
746
+ def direct_world(tmp_path: Path, answers: dict, check, **extra: Any) -> None:
747
+ px = FakeProxy(answers)
748
+ oc = LoopOpenCode()
749
+ verdicts: list[dict] = []
750
+ billed: list = []
751
+
752
+ async def go():
753
+ async with httpx.AsyncClient(trust_env=False) as client:
754
+ runner = AutoRunner(base=oc.url, auth=lambda: {"authorization": "Basic x"}, client=client, state_path=tmp_path / "auto.json",
755
+ log_path=tmp_path / "auto-log.jsonl", poll_ms=30, home="/home/u", on_verdict=verdicts.append,
756
+ on_review=lambda rid, start: billed.append((rid, start)), model_url=px.url, **extra)
757
+ runner.start()
758
+ try:
759
+ runner.set_auto("ses_main", DIR, True)
760
+ oc.ask(permission="bash", patterns=["npm test"], metadata={"command": "npm test"})
761
+ await check(oc, px, verdicts, billed)
762
+ finally:
763
+ runner.stop()
764
+
765
+ try:
766
+ run(go(), timeout=60)
767
+ finally:
768
+ oc.close()
769
+ px.close()
770
+
771
+
772
+ def test_direct_gpt_oss_is_asked_directly_no_opencode_session_low_effort_billed_like_the_session(tmp_path):
773
+ async def check(oc, px, verdicts, billed):
774
+ assert await until(lambda: oc.replies(), 6)
775
+ assert oc.replies()[0]["body"] == {"reply": "once"}
776
+ assert oc.calls("POST", "/session") == [], "no review session"
777
+ c = px.seen[0]
778
+ assert c["path"] == "/v1/chat/completions"
779
+ assert (c["body"]["model"], c["body"]["reasoning_effort"], c["body"]["stream"]) == ("openai/gpt-oss-120b", "low", False)
780
+ assert "HARD DENY" in c["body"]["messages"][0]["content"]
781
+ assert "npm test" in c["body"]["messages"][1]["content"] and "please run the test suite" in c["body"]["messages"][1]["content"]
782
+ assert c["sid"].startswith("ses_review_")
783
+ assert billed == [(c["sid"], True), (c["sid"], False)]
784
+ assert await until(lambda: verdicts, 2)
785
+ assert verdicts[0]["stage"] == "reviewer"
786
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"text": ALLOW}}, check)
787
+
788
+
789
+ def test_direct_the_backup_answers_when_gpt_oss_fails_and_the_log_names_it(tmp_path):
790
+ async def check(oc, px, verdicts, billed):
791
+ assert await until(lambda: oc.replies(), 6)
792
+ assert [s["body"]["model"] for s in px.seen] == ["openai/gpt-oss-120b", "deepseek/deepseek-v4.1-flash"]
793
+ assert await until(lambda: log_lines(tmp_path), 2)
794
+ assert log_lines(tmp_path)[0]["model"] == "trustedrouter/deepseek/deepseek-v4.1-flash"
795
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"status": 502}, "deepseek/deepseek-v4.1-flash": {"text": ALLOW}}, check)
796
+
797
+
798
+ def test_direct_a_first_model_that_hangs_leaves_the_backup_its_time(tmp_path):
799
+ async def check(oc, px, verdicts, billed):
800
+ assert await until(lambda: oc.replies(), 6)
801
+ assert oc.replies()[0]["body"] == {"reply": "once"} and len(px.seen) == 2
802
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"text": ALLOW, "delay": 2}, "deepseek/deepseek-v4.1-flash": {"text": ALLOW}}, check,
803
+ first_try_ms=150, review_timeout_ms=1500)
804
+
805
+
806
+ def test_direct_both_failing_leaves_it_for_the_person_saying_why(tmp_path):
807
+ async def check(oc, px, verdicts, billed):
808
+ assert await until(lambda: verdicts, 6)
809
+ assert verdicts[0]["action"] == "ask"
810
+ assert "gpt-oss-120b: answered 502; deepseek/deepseek-v4.1-flash: no verdict in its answer" in verdicts[0]["reason"]
811
+ await asyncio.sleep(0.2)
812
+ assert oc.replies() == []
813
+ direct_world(tmp_path, {"openai/gpt-oss-120b": {"status": 502}, "deepseek/deepseek-v4.1-flash": {"text": "sure, fine"}}, check)
@@ -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"], []), "both connectors do Auto mode (test_auto.py), save attachments, serve produced files, share what was seen, keep what is waiting (test_asks.py) and allow a folder (test_folders.py)"
177
+ assert (hello["caps"], hello["auto"]) == (["auto", "attachments", "outputs", "tools", "seen", "asks", "folders", "remind"], []), "both connectors do Auto mode (test_auto.py), save attachments, serve produced files, share what was seen, keep what is waiting (test_asks.py) and allow a folder (test_folders.py)"
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
 
@@ -0,0 +1,133 @@
1
+ """"Code is waiting for your approval" (witbitz_code/remind.py = tools/code-remind.mjs; docs/approval-reminders.md).
2
+
3
+ - THE CLOCK: a tickle at one minute, a reminder at ten, never twice, forgotten once the ask is answered.
4
+ - THE TEXT: sealed under the device's key exactly as the service worker opens it (AES-GCM, the shared AAD).
5
+ - BOTH CONNECTORS, for real: a page gives its grant with a prompt, an approval waits, and the computer asks the server to
6
+ push — twice, with the grant, and text only the device can open.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import base64
13
+ import json
14
+ import os
15
+ import threading
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+
18
+ import pytest
19
+ from conftest import requires_node
20
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21
+ from fakes import until
22
+ from test_connector_e2e import JsConnector, PyClient, PyConnector, Rig, run
23
+
24
+ from witbitz_code.remind import AAD, ReminderClock, seal, valid
25
+
26
+ KEY = base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip("=")
27
+ GRANT = "e" * 40 + "." + "s" * 43
28
+
29
+
30
+ def opened(n: str) -> dict:
31
+ b = base64.urlsafe_b64decode(n + "=" * (-len(n) % 4))
32
+ return json.loads(AESGCM(base64.urlsafe_b64decode(KEY + "=")).decrypt(b[:12], b[12:], AAD))
33
+
34
+
35
+ def test_the_clock_tickles_at_one_minute_and_ten_once_each_and_forgets_an_answered_ask():
36
+ t = [0.0]
37
+ c = ReminderClock(now=lambda: t[0])
38
+ ask = {"id": "per_1", "sessionID": "ses_a"}
39
+ assert c.due([ask]) == []
40
+ t[0] = 59
41
+ assert c.due([ask]) == []
42
+ t[0] = 60
43
+ assert c.due([ask]) == [(ask, 1)]
44
+ c.sent("per_1", 1)
45
+ assert c.due([ask]) == []
46
+ t[0] = 600
47
+ assert c.due([ask]) == [(ask, 2)]
48
+ c.sent("per_1", 2)
49
+ t[0] = 10_000
50
+ assert c.due([ask]) == [], "two tickles, then nothing"
51
+ assert c.due([]) == [] and c.due([ask]) == [], "answered → forgotten; a new ask with that id starts again"
52
+
53
+
54
+ def test_the_text_opens_with_the_devices_key_only_and_a_page_message_is_checked():
55
+ n = seal(KEY, {"what": "npm test"})
56
+ assert opened(n) == {"what": "npm test"}
57
+ with pytest.raises(Exception):
58
+ AESGCM(os.urandom(32)).decrypt(base64.urlsafe_b64decode(n + "=" * (-len(n) % 4))[:12], base64.urlsafe_b64decode(n + "=" * (-len(n) % 4))[12:], AAD)
59
+ m = {"sid": "ses_a", "grant": GRANT, "key": KEY, "url": "https://witbitz-spaces.pages.dev/space"}
60
+ assert valid(m) == {"grant": GRANT, "key": KEY, "url": m["url"]}
61
+ assert valid({**m, "url": "http://127.0.0.1:9/space"}) is None, "https only"
62
+ assert valid({**m, "key": "short"}) is None and valid({**m, "grant": "x"}) is None and valid({**m, "sid": ""}) is None
63
+
64
+
65
+ class Receiver:
66
+ def __init__(self):
67
+ self.got: list = []
68
+ outer = self
69
+
70
+ class H(BaseHTTPRequestHandler):
71
+ def log_message(self, *a):
72
+ pass
73
+
74
+ def do_POST(self):
75
+ outer.got.append({"path": self.path, "body": json.loads(self.rfile.read(int(self.headers.get("content-length") or 0)))})
76
+ self.send_response(200)
77
+ self.send_header("content-type", "application/json")
78
+ self.end_headers()
79
+ self.wfile.write(b'{"ok":true}')
80
+
81
+ self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), H)
82
+ self.httpd.daemon_threads = True
83
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
84
+ self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}/space"
85
+
86
+ def close(self):
87
+ self.httpd.shutdown()
88
+ self.httpd.server_close()
89
+
90
+
91
+ def _asked(directory, props):
92
+ return {"directory": directory, "project": "p", "payload": {"id": "evt", "type": "permission.asked", "properties": props}}
93
+
94
+
95
+ ASK = {"id": "per_bash", "sessionID": "ses_main", "permission": "bash", "patterns": ["npm test"], "metadata": {"command": "npm test"}, "always": []}
96
+ CONNECTORS = [pytest.param(PyConnector, {"remindTimes": {"first_s": 0.3, "second_s": 1.0, "every_s": 0.1, "allow_http": True}}, id="python"),
97
+ pytest.param(JsConnector, {"remindTimes": {"firstMs": 300, "secondMs": 1000, "everyMs": 100, "allowHttp": True}}, id="js", marks=requires_node)]
98
+
99
+
100
+ @pytest.mark.parametrize("make,opts", CONNECTORS)
101
+ def test_both_connectors_push_a_waiting_approval_to_the_device_that_asked_twice_sealed_to_it(make, opts, tmp_path):
102
+ rx = Receiver()
103
+
104
+ async def go():
105
+ async with Rig() as rig:
106
+ if make is PyConnector:
107
+ PyConnector.OPTION_NAMES = {**PyConnector.OPTION_NAMES, "remindTimes": "remind_times"}
108
+ connector = make(rig, 30_000, autoDir=str(tmp_path / "auto"), **opts)
109
+ await connector.start()
110
+ client = PyClient(rig.secret, rig.relay.url)
111
+ try:
112
+ await client.start()
113
+ assert "remind" in client.hellos()[-1]["caps"]
114
+ assert await until(lambda: rig.oc.global_count() == 1, 10)
115
+ await client.peer.send({"t": "remind", "k": "stale", "sid": "ses_main", "grant": GRANT, "key": KEY, "url": rx.url}) # a recording: refused
116
+ await client.peer.send({"t": "remind", "k": client.nonce, "sid": "ses_main", "dir": "/home/u/repo", "grant": GRANT, "key": KEY, "url": rx.url})
117
+ await asyncio.sleep(0.2)
118
+ rig.oc.emit_global([_asked("/home/u/repo", ASK)])
119
+ assert await until(lambda: len(rx.got) >= 2, 10), "a tickle at the first mark and a reminder at the second"
120
+ await asyncio.sleep(0.6)
121
+ assert len(rx.got) == 2, "and nothing more"
122
+ for g in rx.got:
123
+ assert g["path"] == "/space" and g["body"]["op"] == "coderemind" and g["body"]["grant"] == GRANT
124
+ assert opened(g["body"]["n"]) == {"where": "test-box", "what": "npm test", "computer": "cmp_test", "session": "ses_main"}
125
+ assert "npm test" not in json.dumps(g["body"]), "the text is sealed"
126
+ finally:
127
+ await client.peer.aclose()
128
+ await connector.stop()
129
+
130
+ try:
131
+ run(go())
132
+ finally:
133
+ rx.close()
@@ -223,7 +223,7 @@ def test_under_the_windows_installer_the_summary_says_what_that_uninstaller_does
223
223
  assert r["done"]
224
224
  for wrong in ("pip uninstall", "pipx", "OpenCode is still installed", "still running in a terminal window"):
225
225
  assert wrong not in said, f"{wrong!r} does not apply to the Windows installer:\n{said}"
226
- assert "irm https://app.witbitz.chat/code.ps1 | iex" in said, said
226
+ assert "https://witbitz.chat/code" in said and "app.witbitz.chat" not in said, said
227
227
  assert ("package",) not in f.done, "the Windows uninstaller removes the program itself"
228
228
 
229
229
 
File without changes
File without changes
File without changes