witbitz-code 1.7.1__tar.gz → 1.7.3__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.1/src/witbitz_code.egg-info → witbitz_code-1.7.3}/PKG-INFO +1 -1
  2. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/pyproject.toml +1 -1
  3. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/confidential.py +5 -1
  5. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/connector.py +63 -5
  6. witbitz_code-1.7.3/src/witbitz_code/remind.py +89 -0
  7. {witbitz_code-1.7.1 → witbitz_code-1.7.3/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  8. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
  9. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_auto.py +1 -1
  10. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_confidential.py +15 -1
  11. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_connector_e2e.py +1 -1
  12. witbitz_code-1.7.3/tests/test_remind.py +133 -0
  13. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/LICENSE +0 -0
  14. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/README.md +0 -0
  15. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/setup.cfg +0 -0
  16. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/__main__.py +0 -0
  17. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/_js.py +0 -0
  18. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/account.py +0 -0
  19. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/asks.py +0 -0
  20. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/attachments.py +0 -0
  21. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/auto.py +0 -0
  22. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/auto_runner.py +0 -0
  23. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/cli.py +0 -0
  24. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/codekeys.py +0 -0
  25. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/codetools.py +0 -0
  26. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/compress.py +0 -0
  27. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/computers.py +0 -0
  28. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/confidential_models.json +0 -0
  29. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/devicelink.py +0 -0
  30. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/dirs.py +0 -0
  31. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/folders.py +0 -0
  32. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/gateway.py +0 -0
  33. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  34. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  35. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/images.py +0 -0
  36. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/keys.py +0 -0
  37. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/link.py +0 -0
  38. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/mkdir.py +0 -0
  39. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/models.py +0 -0
  40. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/net.py +0 -0
  41. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/offered_models.json +0 -0
  42. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  43. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  44. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  45. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  46. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/outputs.py +0 -0
  47. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/pair.py +0 -0
  48. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/pairings.py +0 -0
  49. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/payers.py +0 -0
  50. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/plugins.py +0 -0
  51. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/policy.py +0 -0
  52. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/ports.py +0 -0
  53. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/qr.py +0 -0
  54. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/receipt.py +0 -0
  55. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/relay.py +0 -0
  56. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/seccheck.py +0 -0
  57. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/seen.py +0 -0
  58. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/tinfoil_read.py +0 -0
  59. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/tools_catalog.json +0 -0
  60. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/tools_probe.py +0 -0
  61. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/toolsetup.py +0 -0
  62. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/ui.py +0 -0
  63. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/ui_static/app.css +0 -0
  64. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/ui_static/app.js +0 -0
  65. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/ui_static/index.html +0 -0
  66. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/uitools.py +0 -0
  67. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/uninstall.py +0 -0
  68. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/winagent.py +0 -0
  69. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/winbg.py +0 -0
  70. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/winconsole.py +0 -0
  71. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/wizard.py +0 -0
  72. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code/wslstart.py +0 -0
  73. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  74. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  75. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code.egg-info/requires.txt +0 -0
  76. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/src/witbitz_code.egg-info/top_level.txt +0 -0
  77. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_asks.py +0 -0
  78. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_attachments.py +0 -0
  79. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_cli.py +0 -0
  80. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_codekeys.py +0 -0
  81. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_codetools.py +0 -0
  82. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_devicelink.py +0 -0
  83. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_dirs.py +0 -0
  84. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_files_and_compress.py +0 -0
  85. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_folders.py +0 -0
  86. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_images.py +0 -0
  87. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_outputs.py +0 -0
  88. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_pair_flows.py +0 -0
  89. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_plugins.py +0 -0
  90. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_policy.py +0 -0
  91. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_ports.py +0 -0
  92. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_ports_win.py +0 -0
  93. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_registry.py +0 -0
  94. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_relay_codec.py +0 -0
  95. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_seccheck.py +0 -0
  96. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_setup.py +0 -0
  97. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_tools_probe.py +0 -0
  98. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_toolsetup.py +0 -0
  99. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_ui.py +0 -0
  100. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_uitools.py +0 -0
  101. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_uninstall.py +0 -0
  102. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_winagent.py +0 -0
  103. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_winbg.py +0 -0
  104. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_winconsole.py +0 -0
  105. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/tests/test_windows_files.py +0 -0
  106. {witbitz_code-1.7.1 → witbitz_code-1.7.3}/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.1
3
+ Version: 1.7.3
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.1"
7
+ version = "1.7.3"
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.1"
7
+ __version__ = "1.7.3"
@@ -627,7 +627,11 @@ class ConfidentialProxy:
627
627
  hold = attempt["n"] > 1 or (ends is not None and self._now() / 1000 >= ends - HOLD_BEFORE_LAPSE_SEC)
628
628
  n = self._nonce()
629
629
  raw = dumps(stamp_floor(body)).encode("utf-8")
630
- headers = {**_forward_headers(req.headers), "content-type": "application/json", "x-inference-receipt": n}
630
+ # the forwarded headers WITHOUT their own content-type / x-inference-receipt, whatever their case: OpenCode sends
631
+ # "Content-Type", and two of them made TrustedRouter refuse every confidential call (2026-10-17: a small one 400
632
+ # "could not read request"; a large one closed mid-upload — an empty httpx ReadError, "could not reach TrustedRouter ()")
633
+ fwd = {k: v for k, v in _forward_headers(req.headers).items() if k.lower() not in ("content-type", "x-inference-receipt")}
634
+ headers = {**fwd, "content-type": "application/json", "x-inference-receipt": n}
631
635
  tell("waiting", bytes=len(raw))
632
636
  async with self.client.stream("POST", target, headers=headers, content=raw) as up:
633
637
  if not up.is_success:
@@ -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"
@@ -158,7 +160,7 @@ class PairingServer:
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
162
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
161
- model_url: str = "") -> None:
163
+ model_url: str = "", remind_times: dict | None = None) -> None:
162
164
  self.pairing = pairing
163
165
  self._security: seccheck.SecurityCheck | None = None # read on the first ask (seccheck.py)
164
166
  # WHO PAYS (payers.py): this pairing's account owns the turns it starts; the proxy bills each call to its owner.
@@ -170,6 +172,10 @@ class PairingServer:
170
172
  # WHAT IS WAITING (asks.py): OpenCode's GET /permission breaks for a whole folder on one malformed ask and keeps a
171
173
  # stopped turn's asks — every folder's asks are followed on /global/event, for Auto and for the page's card recovery.
172
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
173
179
  self._asks_live = False
174
180
  self._asks_epoch = 0 # one per connection of the stream
175
181
  self._asks_complete: dict[str, int] = {} # directory → the epoch its whole list was read in: the book knows every ask since
@@ -219,6 +225,7 @@ class PairingServer:
219
225
  # ── lifecycle ─────────────────────────────────────────────────────────────────────────────────────────────────────
220
226
  async def start(self) -> None:
221
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())))
222
229
  self._spawn(self._every(HELLO_EVERY_S, lambda: self.hello() if self.peer.peers >= 2 else None))
223
230
  self._spawn(self._watch_asks())
224
231
  self._spawn(self._every(self._asks_reconcile_s, lambda: self._spawn(self._reconcile_asks()) if self._asks_live else None))
@@ -288,7 +295,7 @@ class PairingServer:
288
295
  return None # no hello before this socket has its nonce
289
296
  # caps: what this connector can do beyond the requests — a page shows the Auto switch only when `auto` is here.
290
297
  return self.peer.send({"t": "hello", "ver": VERSION, "name": self.name, "computerId": self.pairing.get("computerId") or "",
291
- "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()})
292
299
 
293
300
  def _auth(self) -> dict:
294
301
  pw = self.pairing.get("password") or read_env_password(Path(self.pairing["envFile"]) if self.pairing.get("envFile") else None)
@@ -317,6 +324,11 @@ class PairingServer:
317
324
  # page that can switch Auto could already answer the same asks itself.
318
325
  if current and self.auto.set_auto(m.get("sid"), m.get("dir"), _js.truthy(m.get("on"))):
319
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)
320
332
  elif t == "tools":
321
333
  # Which suggested tools this computer has (spaces/public/codeTools.js) — a PATH lookup, nonce-checked like a request.
322
334
  if current:
@@ -487,6 +499,52 @@ class PairingServer:
487
499
  if paid:
488
500
  self._payers.end(paid.group(1))
489
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
+
490
548
  def _own_review(self, session: str, start: bool) -> None:
491
549
  if not self._payers:
492
550
  return
@@ -883,7 +941,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
883
941
  attach_root: Path | None = None, attach_max_file_bytes: int = MAX_FILE_BYTES,
884
942
  asks_reconcile_ms: float = ASKS_RECONCILE_MS, notes_root: str | None = None,
885
943
  notes_plugin_path: Path | None = None, payers: SessionPayers | None = None,
886
- model_url: str = "") -> Connector:
944
+ model_url: str = "", remind_times: dict | None = None) -> Connector:
887
945
  """Serve the given pairings until stop()/aclose(). Options exist for tests: flush_ms, log, client, connect,
888
946
  request_timeout_ms, max_senders, max_response_bytes, auto_dir, auto_poll_ms."""
889
947
  client = client or local_client()
@@ -898,7 +956,7 @@ async def start_connector(pairings: list[dict], *, flush_ms: float = 120, log: C
898
956
  s = PairingServer(p, client=client, flush_ms=flush_ms, log=log, connect=connect, request_timeout_ms=request_timeout_ms,
899
957
  max_senders=max_senders, max_response_bytes=max_response_bytes, auto_dir=auto_dir, auto_poll_ms=auto_poll_ms,
900
958
  attach_root=attach_root, attach_max_file_bytes=attach_max_file_bytes, asks_reconcile_ms=asks_reconcile_ms,
901
- notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url)
959
+ notes_root=notes_root, notes_plugin_path=notes_plugin_path, payers=payers, model_url=model_url, remind_times=remind_times)
902
960
  await s.start()
903
961
  servers.append(s)
904
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.7.1
3
+ Version: 1.7.3
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)
@@ -110,7 +110,7 @@ class FakeNet:
110
110
  def handler(self, req: httpx.Request):
111
111
  if "googleapis.com/service_accounts" in str(req.url):
112
112
  return httpx.Response(200, json=self.meta["googleJwks"])
113
- self.calls.append({"url": str(req.url), "headers": dict(req.headers), "body": req.content.decode("utf-8")})
113
+ self.calls.append({"url": str(req.url), "headers": dict(req.headers), "raw": list(req.headers.raw), "body": req.content.decode("utf-8")})
114
114
  if self.status != 200:
115
115
  return httpx.Response(self.status, json={"error": {"message": "upstream said no"}})
116
116
  data = self.stream.encode("utf-8")
@@ -176,6 +176,20 @@ def test_a_confidential_answer_streams_through_and_the_receipt_verifies_on_the_r
176
176
  with_proxy(t)
177
177
 
178
178
 
179
+ @needs_fixture
180
+ def test_one_content_type_goes_upstream_whatever_case_opencode_sends_it():
181
+ # 2026-10-17: OpenCode sends "Content-Type"; the proxy forwarded it and added its own "content-type", and TrustedRouter
182
+ # refused every confidential call with two (a small one 400 "could not read request", a large one closed mid-upload —
183
+ # an empty httpx ReadError the owner saw as "could not reach TrustedRouter ()").
184
+ async def t(post, net, _p):
185
+ r = await post(SAY_OK, {"Content-Type": "application/json", "X-Inference-Receipt": "from-the-caller"})
186
+ assert r.status_code == 200
187
+ names = [k.decode().lower() for k, _v in net.calls[0]["raw"]]
188
+ assert names.count("content-type") == 1, names
189
+ assert names.count("x-inference-receipt") == 1 and net.calls[0]["headers"]["x-inference-receipt"] != "from-the-caller"
190
+ with_proxy(t)
191
+
192
+
179
193
  @needs_fixture
180
194
  def test_a_receipt_that_does_not_match_ends_the_step_with_an_error_and_no_tool_runs():
181
195
  meta, stream, _ = fixture()
@@ -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()
File without changes
File without changes
File without changes