witbitz-code 1.3.2__tar.gz → 1.3.4__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 (80) hide show
  1. {witbitz_code-1.3.2/src/witbitz_code.egg-info → witbitz_code-1.3.4}/PKG-INFO +3 -2
  2. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/README.md +2 -1
  3. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/pyproject.toml +1 -1
  4. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/cli.py +29 -8
  6. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/codekeys.py +96 -21
  7. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/confidential.py +13 -1
  8. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/pair.py +2 -0
  9. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/payers.py +1 -7
  10. witbitz_code-1.3.4/src/witbitz_code/ports.py +87 -0
  11. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/uninstall.py +1 -1
  12. witbitz_code-1.3.4/src/witbitz_code/winagent.py +111 -0
  13. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/wizard.py +54 -5
  14. {witbitz_code-1.3.2 → witbitz_code-1.3.4/src/witbitz_code.egg-info}/PKG-INFO +3 -2
  15. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/SOURCES.txt +6 -1
  16. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_codekeys.py +115 -0
  17. witbitz_code-1.3.4/tests/test_ports.py +31 -0
  18. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_setup.py +37 -7
  19. witbitz_code-1.3.4/tests/test_winagent.py +69 -0
  20. witbitz_code-1.3.4/tests/test_windows_files.py +28 -0
  21. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/LICENSE +0 -0
  22. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/setup.cfg +0 -0
  23. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/__main__.py +0 -0
  24. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/_js.py +0 -0
  25. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/account.py +0 -0
  26. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/asks.py +0 -0
  27. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/attachments.py +0 -0
  28. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/auto.py +0 -0
  29. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/auto_runner.py +0 -0
  30. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/codetools.py +0 -0
  31. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/compress.py +0 -0
  32. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/computers.py +0 -0
  33. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/confidential_models.json +0 -0
  34. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/connector.py +0 -0
  35. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/devicelink.py +0 -0
  36. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/folders.py +0 -0
  37. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/gateway.py +0 -0
  38. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/keys.py +0 -0
  39. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/link.py +0 -0
  40. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/models.py +0 -0
  41. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/net.py +0 -0
  42. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/offered_models.json +0 -0
  43. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  44. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  45. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  46. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  47. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/outputs.py +0 -0
  48. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/pairings.py +0 -0
  49. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/plugins.py +0 -0
  50. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/policy.py +0 -0
  51. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/qr.py +0 -0
  52. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/receipt.py +0 -0
  53. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/relay.py +0 -0
  54. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/seen.py +0 -0
  55. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/tinfoil_read.py +0 -0
  56. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/tools_probe.py +0 -0
  57. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code/winconsole.py +0 -0
  58. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  59. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  60. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/requires.txt +0 -0
  61. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/src/witbitz_code.egg-info/top_level.txt +0 -0
  62. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_asks.py +0 -0
  63. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_attachments.py +0 -0
  64. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_auto.py +0 -0
  65. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_cli.py +0 -0
  66. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_codetools.py +0 -0
  67. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_confidential.py +0 -0
  68. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_connector_e2e.py +0 -0
  69. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_devicelink.py +0 -0
  70. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_files_and_compress.py +0 -0
  71. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_folders.py +0 -0
  72. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_outputs.py +0 -0
  73. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_pair_flows.py +0 -0
  74. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_plugins.py +0 -0
  75. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_policy.py +0 -0
  76. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_registry.py +0 -0
  77. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_relay_codec.py +0 -0
  78. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_tools_probe.py +0 -0
  79. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_uninstall.py +0 -0
  80. {witbitz_code-1.3.2 → witbitz_code-1.3.4}/tests/test_winconsole.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.2
3
+ Version: 1.3.4
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.2"
7
+ version = "1.3.4"
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.2"
7
+ __version__ = "1.3.4"
@@ -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, winconsole, 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:
@@ -267,15 +284,19 @@ async def setup(args: list[str]) -> int:
267
284
 
268
285
  client = net.new_client()
269
286
 
270
- async def keys_state(p: dict) -> str:
287
+ async def keys_state(p: dict) -> dict:
271
288
  entry = codekeys.for_computer(p.get("computerId") or "")
272
289
  if not entry:
273
- return "none"
290
+ return {"state": "none"}
274
291
  try:
275
292
  st = await codekeys.status(client, entry)
276
- except Exception: # noqa: BLE001 — offline: unknown, never a removal
277
- return "unknown"
278
- return "live" if st.get("live") is True else st.get("why") if st.get("why") in ("revoked", "paused") else "unknown"
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")}
279
300
 
280
301
  d = wizard.Deps(
281
302
  say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
@@ -15,8 +15,8 @@ from __future__ import annotations
15
15
  import base64
16
16
  import json
17
17
  import os
18
- import tempfile
19
18
  import time
19
+ from email.utils import parsedate_to_datetime
20
20
  from pathlib import Path
21
21
  from typing import Any
22
22
 
@@ -114,8 +114,24 @@ def signed_message(op: str, key_id: str, ts: int) -> str:
114
114
  return f"wbcode-v1|{op}|{key_id}|{ts}" # = codeKeys.mjs signedMessage
115
115
 
116
116
 
117
+ # 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
118
+ # clock can be: WSL's falls behind while the laptop sleeps (the owner, 2026-10-08 — setup trusted keys the account had taken
119
+ # back, because the check that would have said so was refused). A refusal carries the server's time in its Date header;
120
+ # from then on this process signs to that time. The signature still proves the key; only `ts` moves.
121
+ SKEW_FIX_S = 60
122
+ _clock = {"skew": 0.0}
123
+
124
+
125
+ def reset_clock() -> None:
126
+ _clock["skew"] = 0.0
127
+
128
+
129
+ def _now() -> float:
130
+ return time.time() + _clock["skew"]
131
+
132
+
117
133
  def signed_body(op: str, entry: dict, now: float | None = None) -> dict:
118
- ts = int(now if now is not None else time.time())
134
+ ts = int(now if now is not None else _now())
119
135
  sig = _sign_key(entry).sign(signed_message(op, entry["keyId"], ts).encode())
120
136
  return {"keyId": entry["keyId"], "ts": ts, "sig": _b64u(sig)}
121
137
 
@@ -130,17 +146,9 @@ def read_all() -> dict:
130
146
 
131
147
 
132
148
  def _write_all(doc: dict) -> None:
133
- path = keys_path()
134
- path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
135
- fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".account-keys.")
136
- try:
137
- os.fchmod(fd, 0o600)
138
- with os.fdopen(fd, "w", encoding="utf-8") as f:
139
- f.write(json.dumps(doc, indent=1) + "\n")
140
- os.replace(tmp, path)
141
- except BaseException:
142
- Path(tmp).unlink(missing_ok=True)
143
- raise
149
+ # pairings.write_private: 0600, atomic, and Windows-safe (Windows Python 3.12 has no os.fchmod — the owner's native
150
+ # Windows pairing died here, the real error hidden by a cleanup that could not delete the still-open temp file).
151
+ pp.write_private(keys_path(), json.dumps(doc, indent=1) + "\n")
144
152
 
145
153
 
146
154
  def for_computer(computer_id: str) -> dict | None:
@@ -153,9 +161,10 @@ def save(entry: dict) -> None:
153
161
  _write_all(doc)
154
162
 
155
163
 
156
- def forget(computer_id: str) -> bool:
164
+ def forget(computer_id: str, key_id: str | None = None) -> bool:
165
+ """Drop this computer's keys — with `key_id`, only if they are still THOSE keys (a re-pair may have replaced them)."""
157
166
  doc = read_all()
158
- keep = [k for k in doc["keys"] if k.get("computerId") != computer_id]
167
+ 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
168
  if len(keep) == len(doc["keys"]):
160
169
  return False
161
170
  doc["keys"] = keep
@@ -214,13 +223,41 @@ async def mint(client: httpx.AsyncClient, *, id_token: str, account: str, comput
214
223
  return entry
215
224
 
216
225
 
226
+ def _server_time(r: httpx.Response) -> float | None:
227
+ try:
228
+ return parsedate_to_datetime(r.headers.get("date") or "").timestamp()
229
+ except (TypeError, ValueError):
230
+ return None
231
+
232
+
233
+ async def _signed_post(client: httpx.AsyncClient, path: str, op: str, entry: dict, timeout: float = 10) -> httpx.Response:
234
+ """A signed call; refused while this computer's clock is off the server's, it is signed again to the server's time."""
235
+ r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
236
+ if r.status_code == 401:
237
+ server = _server_time(r)
238
+ if server is not None and abs(server - _now()) > SKEW_FIX_S:
239
+ _clock["skew"] = server - time.time()
240
+ r = await client.post(api_base() + path, json=signed_body(op, entry), timeout=timeout)
241
+ return r
242
+
243
+
244
+ def _why(r: httpx.Response) -> str:
245
+ """The server's own sentence for a refusal, with its status — what a person reads when the answer could not be had."""
246
+ try:
247
+ msg = str(((r.json() or {}).get("error") or {}).get("message") or "")
248
+ except ValueError:
249
+ msg = ""
250
+ return f"{msg} ({r.status_code})" if msg else f"the account answered {r.status_code}"
251
+
252
+
217
253
  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)
254
+ """{live, why?, month?} — and a Tinfoil key minted or renewed server-side is opened into the entry (the caller saves).
255
+ live None: the answer could not be had, and `why` says why."""
256
+ r = await _signed_post(client, "/status", "status", entry)
220
257
  if r.status_code == 404:
221
258
  return {"live": False, "why": "revoked"}
222
259
  if r.status_code != 200:
223
- return {"live": None, "why": f"status answered {r.status_code}"} # unknown — keep going as before
260
+ return {"live": None, "why": _why(r)} # unknown — keep going as before, and say why
224
261
  d = (r.json() or {}).get("data") or {}
225
262
  tf = (d.get("sealed") or {}).get("tinfoil")
226
263
  if tf:
@@ -234,7 +271,7 @@ async def status(client: httpx.AsyncClient, entry: dict) -> dict:
234
271
  async def revoke(client: httpx.AsyncClient, entry: dict) -> bool:
235
272
  """This computer gives its keys back (unpair, uninstall). True when the server confirmed."""
236
273
  try:
237
- r = await client.post(api_base() + "/revoke", json=signed_body("revoke", entry), timeout=10)
274
+ r = await _signed_post(client, "/revoke", "revoke", entry)
238
275
  except httpx.HTTPError:
239
276
  return False
240
277
  return r.status_code in (200, 404) # 404: already gone
@@ -249,13 +286,39 @@ class LiveKeys:
249
286
  """The keys of the pairings `serve` answers for, and what the server last said about each (status every few hours)."""
250
287
 
251
288
  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))}
289
+ self.ids = list(computer_ids)
290
+ self.entries: dict[str, dict] = {}
253
291
  self.refuse: dict[str, str] = {}
292
+ self.unknown: dict[str, str] = {}
293
+ self._seen: Any = "never"
294
+ self._fresh()
295
+
296
+ def _fresh(self) -> None:
297
+ """Re-read the keys file when it changed: a re-pair while serve runs replaces this computer's keys (the owner,
298
+ 2026-10-08: a running serve kept answering with the old ones — "Invalid API key")."""
299
+ try:
300
+ st = keys_path().stat()
301
+ seen: Any = (st.st_mtime_ns, st.st_size, st.st_ino)
302
+ except OSError:
303
+ seen = None
304
+ if seen == self._seen:
305
+ return
306
+ self._seen = seen
307
+ for c in self.ids:
308
+ e = for_computer(c)
309
+ prev = self.entries.get(c)
310
+ if e:
311
+ if prev and prev.get("keyId") != e.get("keyId"):
312
+ self.refuse.pop(c, None) # new keys: what was said of the old ones is not about these
313
+ self.entries[c] = e
314
+ else:
315
+ self.entries.pop(c, None)
254
316
 
255
317
  def credential(self, computer_id: str | None) -> dict | None:
256
318
  """The proxy's answer for a call owned by `computer_id`: its keys, or {refuse: sentence}. None: no owner."""
257
319
  if not computer_id:
258
320
  return None
321
+ self._fresh()
259
322
  if computer_id in self.refuse:
260
323
  return {"refuse": self.refuse[computer_id]}
261
324
  e = self.entries.get(computer_id)
@@ -264,13 +327,25 @@ class LiveKeys:
264
327
  return {"trustedrouter": e.get("trustedrouter", ""), "tinfoil": e.get("tinfoil", "")}
265
328
 
266
329
  async def check(self, client: httpx.AsyncClient, say: Any = lambda _m: None) -> None:
330
+ self._fresh()
267
331
  for cid, e in list(self.entries.items()):
268
332
  try:
269
333
  st = await status(client, e)
270
334
  except httpx.HTTPError:
271
335
  continue # unknown: keep going as before; the next check asks again
336
+ if st.get("live") is None:
337
+ if self.unknown.get(cid) != st.get("why"):
338
+ say(f'witbitz-code: could not check the coding keys of "{e.get("name")}" with your account — {st.get("why")}')
339
+ self.unknown[cid] = str(st.get("why"))
340
+ continue
341
+ self.unknown.pop(cid, None)
272
342
  if st.get("live") is False and st.get("why") == "revoked":
273
- forget(cid)
343
+ now = for_computer(cid)
344
+ if now and now.get("keyId") != e.get("keyId"): # a re-pair replaced these keys while the account was asked
345
+ self.entries[cid] = now
346
+ self.refuse.pop(cid, None)
347
+ continue
348
+ forget(cid, e.get("keyId"))
274
349
  self.entries.pop(cid, None)
275
350
  self.refuse[cid] = REVOKED_TEXT
276
351
  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 {}
@@ -16,7 +16,6 @@ from __future__ import annotations
16
16
 
17
17
  import json
18
18
  import os
19
- import tempfile
20
19
  import threading
21
20
  from pathlib import Path
22
21
 
@@ -49,12 +48,7 @@ class SessionPayers:
49
48
  if not self._persist:
50
49
  return
51
50
  try:
52
- self._path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
53
- fd, tmp = tempfile.mkstemp(dir=str(self._path.parent), prefix=".session-payers.")
54
- os.fchmod(fd, 0o600)
55
- with os.fdopen(fd, "w", encoding="utf-8") as f:
56
- f.write(json.dumps({"v": 1, "owners": self._owner}))
57
- os.replace(tmp, self._path)
51
+ pp.write_private(self._path, json.dumps({"v": 1, "owners": self._owner})) # Windows-safe (no os.fchmod there)
58
52
  except OSError:
59
53
  pass # memory still knows; a restart forgets, and a forgotten conversation is refused, never misbilled
60
54
 
@@ -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
@@ -170,7 +170,7 @@ def stop_process(pid: int) -> bool:
170
170
  if gone():
171
171
  return True
172
172
  try:
173
- os.kill(pid, signal.SIGKILL)
173
+ os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM)) # Windows has no SIGKILL
174
174
  except OSError:
175
175
  pass
176
176
  time.sleep(0.3)
@@ -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