witbitz-code 1.7.1__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.
- {witbitz_code-1.7.1/src/witbitz_code.egg-info → witbitz_code-1.7.2}/PKG-INFO +1 -1
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/pyproject.toml +1 -1
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/connector.py +63 -5
- witbitz_code-1.7.2/src/witbitz_code/remind.py +89 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/SOURCES.txt +2 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_auto.py +1 -1
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_connector_e2e.py +1 -1
- witbitz_code-1.7.2/tests/test_remind.py +133 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/LICENSE +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/README.md +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/setup.cfg +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/cli.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/icons/witbitz-code.ico +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/images.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/seccheck.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/ui.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/ui_static/app.css +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/ui_static/app.js +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/ui_static/index.html +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/uitools.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/uninstall.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/winbg.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/wslstart.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_asks.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_attachments.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_cli.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_codetools.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_confidential.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_dirs.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_folders.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_images.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_outputs.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_plugins.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_policy.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_ports.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_registry.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_seccheck.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_setup.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_ui.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_uitools.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_winagent.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_winbg.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_windows_files.py +0 -0
- {witbitz_code-1.7.1 → witbitz_code-1.7.2}/tests/test_wslstart.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.7.
|
|
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"
|
|
@@ -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)
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.7.1 → witbitz_code-1.7.2}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|