witbitz-code 1.3.1__tar.gz → 1.3.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 (79) hide show
  1. {witbitz_code-1.3.1/src/witbitz_code.egg-info → witbitz_code-1.3.3}/PKG-INFO +3 -2
  2. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/README.md +2 -1
  3. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/pyproject.toml +1 -1
  4. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/cli.py +44 -4
  6. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/codekeys.py +93 -9
  7. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/confidential.py +13 -1
  8. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/pair.py +2 -0
  9. witbitz_code-1.3.3/src/witbitz_code/ports.py +87 -0
  10. witbitz_code-1.3.3/src/witbitz_code/winagent.py +111 -0
  11. witbitz_code-1.3.3/src/witbitz_code/winconsole.py +60 -0
  12. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/wizard.py +65 -4
  13. {witbitz_code-1.3.1 → witbitz_code-1.3.3/src/witbitz_code.egg-info}/PKG-INFO +3 -2
  14. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/SOURCES.txt +7 -1
  15. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_codekeys.py +115 -0
  16. witbitz_code-1.3.3/tests/test_ports.py +31 -0
  17. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_setup.py +58 -5
  18. witbitz_code-1.3.3/tests/test_winagent.py +69 -0
  19. witbitz_code-1.3.3/tests/test_winconsole.py +58 -0
  20. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/LICENSE +0 -0
  21. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/setup.cfg +0 -0
  22. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/__main__.py +0 -0
  23. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/_js.py +0 -0
  24. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/account.py +0 -0
  25. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/asks.py +0 -0
  26. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/attachments.py +0 -0
  27. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/auto.py +0 -0
  28. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/auto_runner.py +0 -0
  29. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/codetools.py +0 -0
  30. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/compress.py +0 -0
  31. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/computers.py +0 -0
  32. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/confidential_models.json +0 -0
  33. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/connector.py +0 -0
  34. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/devicelink.py +0 -0
  35. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/folders.py +0 -0
  36. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/gateway.py +0 -0
  37. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/keys.py +0 -0
  38. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/link.py +0 -0
  39. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/models.py +0 -0
  40. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/net.py +0 -0
  41. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/offered_models.json +0 -0
  42. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  43. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  44. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  45. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  46. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/outputs.py +0 -0
  47. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/pairings.py +0 -0
  48. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/payers.py +0 -0
  49. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/plugins.py +0 -0
  50. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/policy.py +0 -0
  51. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/qr.py +0 -0
  52. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/receipt.py +0 -0
  53. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/relay.py +0 -0
  54. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/seen.py +0 -0
  55. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/tinfoil_read.py +0 -0
  56. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/tools_probe.py +0 -0
  57. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code/uninstall.py +0 -0
  58. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  59. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  60. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/requires.txt +0 -0
  61. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/top_level.txt +0 -0
  62. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_asks.py +0 -0
  63. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_attachments.py +0 -0
  64. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_auto.py +0 -0
  65. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_cli.py +0 -0
  66. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_codetools.py +0 -0
  67. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_confidential.py +0 -0
  68. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_connector_e2e.py +0 -0
  69. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_devicelink.py +0 -0
  70. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_files_and_compress.py +0 -0
  71. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_folders.py +0 -0
  72. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_outputs.py +0 -0
  73. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_pair_flows.py +0 -0
  74. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_plugins.py +0 -0
  75. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_policy.py +0 -0
  76. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_registry.py +0 -0
  77. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_relay_codec.py +0 -0
  78. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_tools_probe.py +0 -0
  79. {witbitz_code-1.3.1 → witbitz_code-1.3.3}/tests/test_uninstall.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.1
3
+ Version: 1.3.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
@@ -66,7 +66,8 @@ OpenCode's own window on this computer is refused ("coding runs from the Spaces
66
66
  being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
67
67
  a wall between them (they already share its files and shell).
68
68
 
69
- To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
69
+ To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`. (`pipx uninstall` alone
70
+ leaves the pairing and its keys behind, and does not stop a witbitz-code that is still running — `setup` offers to stop one.)
70
71
 
71
72
  The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
72
73
  `witbitz-code pair` and `witbitz-code serve`.
@@ -41,7 +41,8 @@ OpenCode's own window on this computer is refused ("coding runs from the Spaces
41
41
  being written another account cannot add to it. This splits the bill fairly between people who share a computer; it is not
42
42
  a wall between them (they already share its files and shell).
43
43
 
44
- To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`.
44
+ To start over: `witbitz-code uninstall`, then `pipx install witbitz-code` and `witbitz-code setup`. (`pipx uninstall` alone
45
+ leaves the pairing and its keys behind, and does not stop a witbitz-code that is still running — `setup` offers to stop one.)
45
46
 
46
47
  The same thing one step at a time: install OpenCode (`curl -fsSL https://opencode.ai/install | bash`), then
47
48
  `witbitz-code pair` and `witbitz-code serve`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.3.1"
7
+ version = "1.3.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.3.1"
7
+ __version__ = "1.3.3"
@@ -17,6 +17,7 @@ import os
17
17
  import signal
18
18
  import subprocess
19
19
  import sys
20
+ import time
20
21
  from pathlib import Path
21
22
  from typing import Any
22
23
 
@@ -25,7 +26,7 @@ import httpx
25
26
  from . import __version__
26
27
  from . import pair as pair_cmd
27
28
  from . import pairings as pp
28
- from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, wizard
29
+ from . import codekeys, codetools, confidential, keys, models, plugins, policy, uninstall as uninstall_mod, winagent, winconsole, wizard
29
30
  from ._js import to_number, truthy
30
31
  from .connector import pairings_for_port, start_connector
31
32
  from .payers import SessionPayers
@@ -176,6 +177,11 @@ async def serve(args: list[str]) -> int:
176
177
  tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
177
178
  if note:
178
179
  _err(note)
180
+ # On Windows: UTF-8 Python, Git Bash, the agent's own Python and the note naming them (winagent.py; measured).
181
+ w_add, w_instr, w_note = await asyncio.to_thread(winagent.windows_agent, plugins.opencode_config_dir())
182
+ if w_note:
183
+ _err(w_note)
184
+ tools_env, tools_config = winagent.apply(tools_env, tools_config, w_add, w_instr)
179
185
  # THIS COMPUTER'S CODING KEYS (codekeys.py; docs/code-account-keys.md): the only model keys witbitz-code uses. A pairing
180
186
  # made before them is not served — every call of its account would be refused.
181
187
  unkeyed = [p for p in pairings if not codekeys.for_computer(p.get("computerId") or "")]
@@ -201,11 +207,22 @@ async def serve(args: list[str]) -> int:
201
207
  # THE PROXY (confidential.py): started BEFORE OpenCode, which is always pointed at it — it puts each account's key on
202
208
  # the calls of that account's prompts, enforces the confidential models and refuses a model Witbitz does not offer.
203
209
  pport = models.proxy_port_for(port)
210
+ recheck = {"at": -1e9}
211
+
212
+ def key_refused() -> None: # TrustedRouter refused a key: ask the account now (at most once a minute), not in hours
213
+ if time.monotonic() - recheck["at"] >= 60:
214
+ recheck["at"] = time.monotonic()
215
+ asyncio.ensure_future(live_keys.check(keys_http, _err))
216
+
204
217
  try:
205
218
  proxy = await confidential.start_confidential_proxy(port=pport, log=_err, on_progress=lambda ev: live["connector"] and live["connector"].progress(ev),
206
- credential_for=credential_for, offered=lambda m: m in offered)
219
+ credential_for=credential_for, offered=lambda m: m in offered,
220
+ on_key_refused=key_refused)
207
221
  except OSError as e:
208
- _err(f"witbitz-code: could not start the model proxy on 127.0.0.1:{pport} ({e}) — free that port and run serve again")
222
+ held = "; ".join(f"{(o.get('cmd') or 'a program')[:70]} (process {o['pid']})" for o in ports.port_owners(pport))
223
+ _err(f"witbitz-code: could not start the model proxy on 127.0.0.1:{pport} ({e})"
224
+ + (f" — held by {held}: an earlier witbitz-code still running? witbitz-code setup offers to stop it" if held
225
+ else " — free that port and run serve again"))
209
226
  return 1
210
227
  route = models.proxy_config(proxy.port)
211
228
  if await is_listening(port) and "--no-opencode" not in args:
@@ -263,13 +280,35 @@ async def _check_keys_every(live_keys: codekeys.LiveKeys, http: httpx.AsyncClien
263
280
  async def setup(args: list[str]) -> int:
264
281
  port_n = to_number(_flag(args, "--port", "4096"))
265
282
  port = int(port_n) if truthy(port_n) and port_n == port_n and float(port_n).is_integer() else 4096
283
+ from . import net # noqa: PLC0415
284
+
285
+ client = net.new_client()
286
+
287
+ async def keys_state(p: dict) -> dict:
288
+ entry = codekeys.for_computer(p.get("computerId") or "")
289
+ if not entry:
290
+ return {"state": "none"}
291
+ try:
292
+ st = await codekeys.status(client, entry)
293
+ except Exception as e: # noqa: BLE001 — offline: unknown, never a removal
294
+ return {"state": "unknown", "why": f"could not reach Witbitz ({str(e)[:80]})"}
295
+ if st.get("live") is True:
296
+ return {"state": "live"}
297
+ if st.get("why") in ("revoked", "paused"):
298
+ return {"state": st["why"]}
299
+ return {"state": "unknown", "why": str(st.get("why") or "no answer")}
300
+
266
301
  d = wizard.Deps(
267
302
  say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
268
303
  port=port, port_explicit="--port" in args,
269
304
  pair=lambda p: pair_cmd.main(["--port", str(p)]),
305
+ keys_state=keys_state,
270
306
  is_listening=is_listening, serve_here=lambda p: serve(["--port", str(p)]),
271
307
  )
272
- r = await wizard.run_setup(d)
308
+ try:
309
+ r = await wizard.run_setup(d)
310
+ finally:
311
+ await client.aclose()
273
312
  return r.get("served", 0) if not r.get("stopped") else 1
274
313
 
275
314
 
@@ -352,6 +391,7 @@ async def _main(argv: list[str]) -> int:
352
391
 
353
392
 
354
393
  def main(argv: list[str] | None = None) -> Any:
394
+ winconsole.prepare() # Windows: UTF-8 when not a console, ANSI colours for the QR when it is (winconsole.py)
355
395
  try:
356
396
  code = asyncio.run(_main(sys.argv[1:] if argv is None else argv))
357
397
  except KeyboardInterrupt:
@@ -17,6 +17,7 @@ import json
17
17
  import os
18
18
  import tempfile
19
19
  import time
20
+ from email.utils import parsedate_to_datetime
20
21
  from pathlib import Path
21
22
  from typing import Any
22
23
 
@@ -114,8 +115,24 @@ def signed_message(op: str, key_id: str, ts: int) -> str:
114
115
  return f"wbcode-v1|{op}|{key_id}|{ts}" # = codeKeys.mjs signedMessage
115
116
 
116
117
 
118
+ # THIS COMPUTER'S CLOCK, as the server sees it. The server refuses a signed call more than 5 minutes off its own time, and a
119
+ # clock can be: WSL's falls behind while the laptop sleeps (the owner, 2026-10-08 — setup trusted keys the account had taken
120
+ # back, because the check that would have said so was refused). A refusal carries the server's time in its Date header;
121
+ # from then on this process signs to that time. The signature still proves the key; only `ts` moves.
122
+ SKEW_FIX_S = 60
123
+ _clock = {"skew": 0.0}
124
+
125
+
126
+ def reset_clock() -> None:
127
+ _clock["skew"] = 0.0
128
+
129
+
130
+ def _now() -> float:
131
+ return time.time() + _clock["skew"]
132
+
133
+
117
134
  def signed_body(op: str, entry: dict, now: float | None = None) -> dict:
118
- ts = int(now if now is not None else time.time())
135
+ ts = int(now if now is not None else _now())
119
136
  sig = _sign_key(entry).sign(signed_message(op, entry["keyId"], ts).encode())
120
137
  return {"keyId": entry["keyId"], "ts": ts, "sig": _b64u(sig)}
121
138
 
@@ -153,9 +170,10 @@ def save(entry: dict) -> None:
153
170
  _write_all(doc)
154
171
 
155
172
 
156
- def forget(computer_id: str) -> bool:
173
+ def forget(computer_id: str, key_id: str | None = None) -> bool:
174
+ """Drop this computer's keys — with `key_id`, only if they are still THOSE keys (a re-pair may have replaced them)."""
157
175
  doc = read_all()
158
- keep = [k for k in doc["keys"] if k.get("computerId") != computer_id]
176
+ keep = [k for k in doc["keys"] if k.get("computerId") != computer_id or (key_id is not None and k.get("keyId") != key_id)]
159
177
  if len(keep) == len(doc["keys"]):
160
178
  return False
161
179
  doc["keys"] = keep
@@ -214,13 +232,41 @@ async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, comput
214
232
  return entry
215
233
 
216
234
 
235
+ def _server_time(r: httpx.Response) -> float | None:
236
+ try:
237
+ return parsedate_to_datetime(r.headers.get("date") or "").timestamp()
238
+ except (TypeError, ValueError):
239
+ return None
240
+
241
+
242
+ async def _signed_post(client: httpx.AsyncClient, path: str, op: str, entry: dict, timeout: float = 10) -> httpx.Response:
243
+ """A signed call; refused while this computer's clock is off the server's, it is signed again to the server's time."""
244
+ r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
245
+ if r.status_code == 401:
246
+ server = _server_time(r)
247
+ if server is not None and abs(server - _now()) > SKEW_FIX_S:
248
+ _clock["skew"] = server - time.time()
249
+ r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
250
+ return r
251
+
252
+
253
+ def _why(r: httpx.Response) -> str:
254
+ """The server's own sentence for a refusal, with its status — what a person reads when the answer could not be had."""
255
+ try:
256
+ msg = str(((r.json() or {}).get("error") or {}).get("message") or "")
257
+ except ValueError:
258
+ msg = ""
259
+ return f"{msg} ({r.status_code})" if msg else f"the account answered {r.status_code}"
260
+
261
+
217
262
  async def status(client: httpx.AsyncClient, entry: dict) -> dict:
218
- """{live, why?, month?} — and a Tinfoil key minted or renewed server-side is opened into the entry (the caller saves)."""
219
- r = await client.post(api_base() + "/status", json=signed_body("status", entry), timeout=10)
263
+ """{live, why?, month?} — and a Tinfoil key minted or renewed server-side is opened into the entry (the caller saves).
264
+ live None: the answer could not be had, and `why` says why."""
265
+ r = await _signed_post(client, "/status", "status", entry)
220
266
  if r.status_code == 404:
221
267
  return {"live": False, "why": "revoked"}
222
268
  if r.status_code != 200:
223
- return {"live": None, "why": f"status answered {r.status_code}"} # unknown — keep going as before
269
+ return {"live": None, "why": _why(r)} # unknown — keep going as before, and say why
224
270
  d = (r.json() or {}).get("data") or {}
225
271
  tf = (d.get("sealed") or {}).get("tinfoil")
226
272
  if tf:
@@ -234,7 +280,7 @@ async def status(client: httpx.AsyncClient, entry: dict) -> dict:
234
280
  async def revoke(client: httpx.AsyncClient, entry: dict) -> bool:
235
281
  """This computer gives its keys back (unpair, uninstall). True when the server confirmed."""
236
282
  try:
237
- r = await client.post(api_base() + "/revoke", json=signed_body("revoke", entry), timeout=10)
283
+ r = await _signed_post(client, "/revoke", "revoke", entry)
238
284
  except httpx.HTTPError:
239
285
  return False
240
286
  return r.status_code in (200, 404) # 404: already gone
@@ -249,13 +295,39 @@ class LiveKeys:
249
295
  """The keys of the pairings `serve` answers for, and what the server last said about each (status every few hours)."""
250
296
 
251
297
  def __init__(self, computer_ids: list[str]) -> None:
252
- self.entries: dict[str, dict] = {c: e for c in computer_ids if (e := for_computer(c))}
298
+ self.ids = list(computer_ids)
299
+ self.entries: dict[str, dict] = {}
253
300
  self.refuse: dict[str, str] = {}
301
+ self.unknown: dict[str, str] = {}
302
+ self._seen: Any = "never"
303
+ self._fresh()
304
+
305
+ def _fresh(self) -> None:
306
+ """Re-read the keys file when it changed: a re-pair while serve runs replaces this computer's keys (the owner,
307
+ 2026-10-08: a running serve kept answering with the old ones — "Invalid API key")."""
308
+ try:
309
+ st = keys_path().stat()
310
+ seen: Any = (st.st_mtime_ns, st.st_size, st.st_ino)
311
+ except OSError:
312
+ seen = None
313
+ if seen == self._seen:
314
+ return
315
+ self._seen = seen
316
+ for c in self.ids:
317
+ e = for_computer(c)
318
+ prev = self.entries.get(c)
319
+ if e:
320
+ if prev and prev.get("keyId") != e.get("keyId"):
321
+ self.refuse.pop(c, None) # new keys: what was said of the old ones is not about these
322
+ self.entries[c] = e
323
+ else:
324
+ self.entries.pop(c, None)
254
325
 
255
326
  def credential(self, computer_id: str | None) -> dict | None:
256
327
  """The proxy's answer for a call owned by `computer_id`: its keys, or {refuse: sentence}. None: no owner."""
257
328
  if not computer_id:
258
329
  return None
330
+ self._fresh()
259
331
  if computer_id in self.refuse:
260
332
  return {"refuse": self.refuse[computer_id]}
261
333
  e = self.entries.get(computer_id)
@@ -264,13 +336,25 @@ class LiveKeys:
264
336
  return {"trustedrouter": e.get("trustedrouter", ""), "tinfoil": e.get("tinfoil", "")}
265
337
 
266
338
  async def check(self, client: httpx.AsyncClient, say: Any = lambda _m: None) -> None:
339
+ self._fresh()
267
340
  for cid, e in list(self.entries.items()):
268
341
  try:
269
342
  st = await status(client, e)
270
343
  except httpx.HTTPError:
271
344
  continue # unknown: keep going as before; the next check asks again
345
+ if st.get("live") is None:
346
+ if self.unknown.get(cid) != st.get("why"):
347
+ say(f'witbitz-code: could not check the coding keys of "{e.get("name")}" with your account — {st.get("why")}')
348
+ self.unknown[cid] = str(st.get("why"))
349
+ continue
350
+ self.unknown.pop(cid, None)
272
351
  if st.get("live") is False and st.get("why") == "revoked":
273
- forget(cid)
352
+ now = for_computer(cid)
353
+ if now and now.get("keyId") != e.get("keyId"): # a re-pair replaced these keys while the account was asked
354
+ self.entries[cid] = now
355
+ self.refuse.pop(cid, None)
356
+ continue
357
+ forget(cid, e.get("keyId"))
274
358
  self.entries.pop(cid, None)
275
359
  self.refuse[cid] = REVOKED_TEXT
276
360
  say(f'witbitz-code: the coding keys of "{e.get("name")}" → {e.get("account")} were revoked — pair again to code from that account')
@@ -457,7 +457,7 @@ class ConfidentialProxy:
457
457
  reader: Any = "default", log: Callable[[str], Any] = print, policies: set | None = None,
458
458
  verify: Callable | None = None, sleep: Callable = asyncio.sleep, on_progress: Callable[[dict], Any] = lambda ev: None,
459
459
  cache_marks: bool = True, credential_for: Callable[["_Request"], dict | None] | None = None,
460
- offered: Callable[[str], bool] | None = None) -> None:
460
+ offered: Callable[[str], bool] | None = None, on_key_refused: Callable[[], Any] = lambda: None) -> None:
461
461
  self.port, self.host, self.upstream = port, host, upstream
462
462
  self.client = client or httpx.AsyncClient(trust_env=False, timeout=httpx.Timeout(None, connect=15.0))
463
463
  self.models = models if models is not None else mdl.confidential_models()
@@ -473,6 +473,16 @@ class ConfidentialProxy:
473
473
  # key OpenCode sends is used as is (tests, and a proxy run by hand).
474
474
  self._credential_for = credential_for
475
475
  self._offered = offered
476
+ # TrustedRouter refused an account's key (401): it was taken back — a removal in the app, a re-pair. serve asks the
477
+ # account at once instead of at its next check, hours later (the owner, 2026-10-08: "Invalid API key").
478
+ self._on_key_refused = on_key_refused
479
+
480
+ def _key_refused(self, status: int) -> None:
481
+ if status == 401 and self._credential_for is not None:
482
+ try:
483
+ self._on_key_refused()
484
+ except Exception: # noqa: BLE001 — a hook must never fail a call
485
+ pass
476
486
 
477
487
  def add_confidential(self, lst: Any) -> int:
478
488
  """Models the account offers as confidential today — added, never removed (models.add_confidential)."""
@@ -579,6 +589,7 @@ class ConfidentialProxy:
579
589
  try:
580
590
  async with self.client.stream(req.method, target, headers=_forward_headers(req.headers), content=None if req.method in ("GET", "HEAD") else out) as up:
581
591
  headers = {k: v for k, v in up.headers.items() if k.lower() not in ("content-encoding", "content-length", "transfer-encoding", "connection")}
592
+ self._key_refused(up.status_code)
582
593
  await res.head(up.status_code, headers)
583
594
  async for c in up.aiter_bytes():
584
595
  await res.write(c)
@@ -614,6 +625,7 @@ class ConfidentialProxy:
614
625
  tell("waiting", bytes=len(raw))
615
626
  async with self.client.stream("POST", target, headers=headers, content=raw) as up:
616
627
  if not up.is_success:
628
+ self._key_refused(up.status_code)
617
629
  text = (await up.aread()).decode("utf-8", "replace")
618
630
  if res.headers_sent:
619
631
  raise Refusal(502, f"{label}: TrustedRouter answered {up.status_code} when asked again ({text[:160]})", "upstream_error")
@@ -279,6 +279,8 @@ async def _keys_line(p: dict, keys: Any) -> str:
279
279
  st = await keys.status(entry)
280
280
  except Exception as e: # noqa: BLE001 — status is a reading, never a failure of the command
281
281
  return f"coding keys: could not ask ({str(e)[:60]})"
282
+ if st.get("live") is None:
283
+ return f"coding keys: could not check with your account — {st.get('why')}"
282
284
  if st.get("live") is False:
283
285
  return "coding keys: revoked — pair again: witbitz-code pair" if st.get("why") == "revoked" else "coding keys: paused by Witbitz"
284
286
  m = st.get("month") or {}
@@ -0,0 +1,87 @@
1
+ """Who is listening on a port of this computer — this user's processes only (= tools/witbitz-code.mjs portOwners).
2
+
3
+ setup asks it before starting: an earlier witbitz-code still running (`pipx uninstall` does not stop it) holds OpenCode's
4
+ port and the model proxy's (port + 100), and keeps answering the phone with the keys it started with (the owner,
5
+ 2026-10-08: "could not start the model proxy … address already in use", then "Invalid API key"). Linux reads /proc; macOS
6
+ asks lsof; elsewhere nothing is known and setup says the port is taken.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+
17
+ def port_owners(port: int) -> list[dict]:
18
+ """[{pid, cmd}] of this user's processes listening on 127.0.0.1/::1 `port` (never this process)."""
19
+ if Path("/proc/net/tcp").exists():
20
+ return _proc_owners(port)
21
+ if shutil.which("lsof"):
22
+ return _lsof_owners(port)
23
+ return []
24
+
25
+
26
+ def _proc_owners(port: int) -> list[dict]:
27
+ inodes: set[str] = set()
28
+ for f in ("/proc/net/tcp", "/proc/net/tcp6"):
29
+ try:
30
+ lines = Path(f).read_text().splitlines()[1:]
31
+ except OSError:
32
+ continue
33
+ for line in lines:
34
+ c = line.split()
35
+ if len(c) >= 10 and c[3] == "0A" and int(c[1].rsplit(":", 1)[-1], 16) == port: # 0A = LISTEN
36
+ inodes.add(c[9])
37
+ if not inodes:
38
+ return []
39
+ hits = []
40
+ for p in os.listdir("/proc"):
41
+ if not p.isdigit() or int(p) == os.getpid():
42
+ continue
43
+ try:
44
+ fds = os.listdir(f"/proc/{p}/fd")
45
+ except OSError:
46
+ continue # not this user's
47
+ owns = False
48
+ for fd in fds:
49
+ try:
50
+ link = os.readlink(f"/proc/{p}/fd/{fd}")
51
+ except OSError:
52
+ continue
53
+ if link.startswith("socket:[") and link[8:-1] in inodes:
54
+ owns = True
55
+ break
56
+ if not owns:
57
+ continue
58
+ try:
59
+ cmd = " ".join(x for x in Path(f"/proc/{p}/cmdline").read_bytes().decode("utf-8", "replace").split("\0") if x)
60
+ except OSError:
61
+ cmd = ""
62
+ hits.append({"pid": int(p), "cmd": cmd})
63
+ return hits
64
+
65
+
66
+ def _lsof_owners(port: int) -> list[dict]:
67
+ try:
68
+ out = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], capture_output=True, text=True, timeout=10).stdout
69
+ except (OSError, subprocess.SubprocessError):
70
+ return []
71
+ hits = []
72
+ for pid in sorted({int(x) for x in out.split() if x.isdigit()}):
73
+ if pid == os.getpid():
74
+ continue
75
+ try:
76
+ cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True, timeout=5).stdout.strip()
77
+ except (OSError, subprocess.SubprocessError):
78
+ cmd = ""
79
+ hits.append({"pid": pid, "cmd": cmd})
80
+ return hits
81
+
82
+
83
+ def on_wsl() -> bool:
84
+ try:
85
+ return "microsoft" in Path("/proc/version").read_text().lower()
86
+ except OSError:
87
+ return False
@@ -0,0 +1,111 @@
1
+ """What OpenCode gets on Windows, measured (2026-10-08, Windows 11, OpenCode 1.18.30; six teacher tasks with Hebrew data on
2
+ DeepSeek V4.1 Flash, Claude Haiku 4.5 and GLM 5.3 Flash — 48/48 correct with all of this, 45/48 without):
3
+
4
+ - PYTHONUTF8=1 — Windows Python writes cp1252 to a pipe and died printing Hebrew ("'charmap' codec can't encode").
5
+ - Git Bash as the shell (SHELL) where it is installed — a model's bash habits (heredocs, /c/ paths in a shell) just work
6
+ there; in PowerShell 5.1 Haiku opened 7 of 12 runs with a heredoc even when told not to.
7
+ - A Python of its own first on PATH, with a python3 that is real — the bare name is the Microsoft Store stub, which prints
8
+ "Python" and fails. code.ps1 makes it: %LOCALAPPDATA%\\Witbitz\\agent-python, a uv venv with python3.exe beside python.exe.
9
+ - An instructions file saying all that — above all that OpenCode's read/write tools are Windows programs: under Git Bash,
10
+ Haiku wrote "/c/Users/…" with the write tool, which landed in C:\\c\\Users\\… while it reported Done.
11
+
12
+ Off Windows nothing changes. code.ps1 (spaces/public) installs the Git and the Python this looks for."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import ntpath
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Callable
21
+
22
+ INSTRUCTIONS_NAME = "witbitz-windows.md"
23
+
24
+ GIT_BASH = """# This computer
25
+ - Windows. Your shell is Git Bash. In shell commands paths look like /c/Users/name/folder, but the read/write/edit tools are Windows programs and need C:\\Users\\name\\folder (or a path relative to the working directory). Never pass /c/... to those tools.
26
+ - Python: run `python` or `python3` (a Python of your own is first on PATH). If a module is missing, `python -m pip install <name>`.
27
+ - Files with non-English text (e.g. Hebrew) are UTF-8. Prefer Python for CSV, Excel and Word files.
28
+ - pip's "[notice] A new release of pip" line is not an error.
29
+ """
30
+
31
+ POWERSHELL = """# This computer
32
+ - Windows. Your shell is Windows PowerShell 5.1. Paths look like C:\\Users\\name\\folder - use that form everywhere, including in the read/write/edit tools.
33
+ - Python: run `python` or `python3` (a Python of your own is first on PATH, when there is one). If a module is missing, `python -m pip install <name>`.
34
+ - Files with non-English text (e.g. Hebrew) are UTF-8. In PowerShell always pass `-Encoding UTF8` to Get-Content, Set-Content, Out-File, Import-Csv and Export-Csv. Prefer Python for CSV, Excel and Word files.
35
+ - PowerShell has no here-documents: `python << 'EOF'` does not work. For anything longer than one line, write a .py file and run `python file.py`.
36
+ - pip's "[notice] A new release of pip" line is not an error.
37
+ """
38
+
39
+
40
+ def _witbitz_dir(env: dict) -> str:
41
+ return ntpath.join(env.get("LOCALAPPDATA", ""), "Witbitz")
42
+
43
+
44
+ def find_git_bash(env: dict, exists: Callable[[str], bool]) -> str | None:
45
+ """Git Bash: the one code.ps1 installs first, then Git for Windows' usual places."""
46
+ candidates = [env.get("WITBITZ_GIT_BASH", ""), ntpath.join(_witbitz_dir(env), "git", "bin", "bash.exe")]
47
+ for root in (env.get("ProgramFiles", ""), env.get("ProgramW6432", ""), env.get("ProgramFiles(x86)", "")):
48
+ if root:
49
+ candidates.append(ntpath.join(root, "Git", "bin", "bash.exe"))
50
+ if env.get("LOCALAPPDATA"):
51
+ candidates.append(ntpath.join(env["LOCALAPPDATA"], "Programs", "Git", "bin", "bash.exe"))
52
+ return next((c for c in candidates if c and exists(c)), None)
53
+
54
+
55
+ def find_agent_python(env: dict, exists: Callable[[str], bool]) -> str | None:
56
+ """The Scripts folder of the agent's own Python (code.ps1 makes it), or None."""
57
+ home = env.get("WITBITZ_AGENT_PYTHON") or ntpath.join(_witbitz_dir(env), "agent-python")
58
+ scripts = ntpath.join(home, "Scripts")
59
+ return scripts if exists(ntpath.join(scripts, "python.exe")) else None
60
+
61
+
62
+ def _write_once(f: Path, text: str) -> None:
63
+ try:
64
+ if f.read_text(encoding="utf-8") == text:
65
+ return
66
+ except (OSError, UnicodeDecodeError):
67
+ pass
68
+ f.parent.mkdir(parents=True, exist_ok=True)
69
+ f.write_text(text, encoding="utf-8", newline="")
70
+
71
+
72
+ def windows_agent(config_dir: Path, env: dict | None = None, platform: str | None = None,
73
+ exists: Callable[[str], bool] = os.path.exists) -> tuple[dict, str | None, str]:
74
+ """→ (env additions — PATH_PREPEND is a list for apply(), the instructions file or None, a line for the log or "")."""
75
+ platform = sys.platform if platform is None else platform
76
+ if platform != "win32":
77
+ return {}, None, ""
78
+ env = os.environ if env is None else env
79
+ add: dict = {"PYTHONUTF8": "1"}
80
+ bash = find_git_bash(env, exists)
81
+ if bash:
82
+ add["SHELL"] = bash
83
+ py = find_agent_python(env, exists)
84
+ if py:
85
+ add["PATH_PREPEND"] = [py]
86
+ f = Path(config_dir) / INSTRUCTIONS_NAME
87
+ instr: str | None = None
88
+ try:
89
+ _write_once(f, GIT_BASH if bash else POWERSHELL)
90
+ instr = str(f)
91
+ except OSError:
92
+ pass
93
+ shell = f"Git Bash ({bash})" if bash else "PowerShell (no Git Bash found)"
94
+ pyline = f"Python {ntpath.dirname(py)}" if py else "no Python of its own (run the installer again to add it)"
95
+ return add, instr, f"witbitz-code: on Windows — shell {shell}, {pyline}, UTF-8 for Python"
96
+
97
+
98
+ def apply(tools_env: dict, tools_config: dict, add: dict, instructions: str | None, base_path: str | None = None) -> tuple[dict, dict]:
99
+ """Fold windows_agent's result into codetools' (env, config), without changing either: the agent's Python goes first on
100
+ PATH (ahead of ast-grep), and its instructions file joins the list (OpenCode's config merge replaces lists)."""
101
+ env = {k: v for k, v in tools_env.items()}
102
+ for k, v in add.items():
103
+ if k != "PATH_PREPEND":
104
+ env[k] = v
105
+ if add.get("PATH_PREPEND"):
106
+ rest = env.get("PATH", base_path if base_path is not None else os.environ.get("PATH", ""))
107
+ env["PATH"] = ";".join(list(add["PATH_PREPEND"]) + ([rest] if rest else []))
108
+ config = {k: v for k, v in tools_config.items()}
109
+ if instructions:
110
+ config["instructions"] = list(tools_config.get("instructions") or []) + [instructions]
111
+ return env, config
@@ -0,0 +1,60 @@
1
+ """Windows output, set up once before anything is printed (tests/test_winconsole.py).
2
+
3
+ - Not a console (piped, redirected, captured by another program — an installer, a service log): Windows Python writes the
4
+ locale's code page, cp1252, and the first arrow or QR block crashed witbitz-code ("'charmap' codec can't encode
5
+ character '\\u2192'", measured on Windows 11 with 1.3.0). Such a stream is switched to UTF-8.
6
+ - A console: the QR is drawn with ANSI colours (qr.render_ansi), which the console shows only once its virtual terminal
7
+ mode is on — Windows Terminal has it, an older console window prints the codes as text. It is turned on here.
8
+
9
+ Off Windows, and wherever the console cannot say what it is, nothing changes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from typing import Any
16
+
17
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
18
+ STD_HANDLES = (-11, -12) # stdout, stderr
19
+
20
+
21
+ def _kernel32() -> Any:
22
+ import ctypes # noqa: PLC0415 — Windows only
23
+
24
+ return ctypes.windll.kernel32 # type: ignore[attr-defined]
25
+
26
+
27
+ def prepare(*, platform: str = sys.platform, streams: tuple = (), kernel32: Any = "auto") -> None:
28
+ if platform != "win32":
29
+ return
30
+ streams = streams or (sys.stdout, sys.stderr)
31
+ console = []
32
+ for i, s in enumerate(streams):
33
+ try:
34
+ tty = bool(s.isatty())
35
+ except (AttributeError, ValueError):
36
+ tty = False
37
+ if tty:
38
+ console.append(STD_HANDLES[i] if i < len(STD_HANDLES) else None)
39
+ continue
40
+ try:
41
+ s.reconfigure(encoding="utf-8", errors="replace")
42
+ except (AttributeError, ValueError):
43
+ pass
44
+ if not any(h is not None for h in console):
45
+ return
46
+ try:
47
+ import ctypes # noqa: PLC0415
48
+
49
+ k = _kernel32() if kernel32 == "auto" else kernel32
50
+ if k is None:
51
+ return
52
+ for n in console:
53
+ if n is None:
54
+ continue
55
+ handle = k.GetStdHandle(n)
56
+ mode = ctypes.c_ulong()
57
+ if k.GetConsoleMode(handle, ctypes.byref(mode)):
58
+ k.SetConsoleMode(handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING)
59
+ except Exception: # noqa: BLE001 — an old console keeps the raw code line below the QR
60
+ pass