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