witbitz-code 1.3.2__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.2/src/witbitz_code.egg-info → witbitz_code-1.3.3}/PKG-INFO +3 -2
  2. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/README.md +2 -1
  3. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/pyproject.toml +1 -1
  4. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/__init__.py +1 -1
  5. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/cli.py +29 -8
  6. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/codekeys.py +93 -9
  7. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/confidential.py +13 -1
  8. {witbitz_code-1.3.2 → 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.2 → witbitz_code-1.3.3}/src/witbitz_code/wizard.py +54 -5
  12. {witbitz_code-1.3.2 → witbitz_code-1.3.3/src/witbitz_code.egg-info}/PKG-INFO +3 -2
  13. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
  14. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_codekeys.py +115 -0
  15. witbitz_code-1.3.3/tests/test_ports.py +31 -0
  16. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_setup.py +37 -7
  17. witbitz_code-1.3.3/tests/test_winagent.py +69 -0
  18. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/LICENSE +0 -0
  19. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/setup.cfg +0 -0
  20. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/__main__.py +0 -0
  21. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/_js.py +0 -0
  22. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/account.py +0 -0
  23. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/asks.py +0 -0
  24. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/attachments.py +0 -0
  25. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/auto.py +0 -0
  26. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/auto_runner.py +0 -0
  27. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/codetools.py +0 -0
  28. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/compress.py +0 -0
  29. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/computers.py +0 -0
  30. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/confidential_models.json +0 -0
  31. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/connector.py +0 -0
  32. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/devicelink.py +0 -0
  33. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/folders.py +0 -0
  34. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/gateway.py +0 -0
  35. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/keys.py +0 -0
  36. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/link.py +0 -0
  37. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/models.py +0 -0
  38. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/net.py +0 -0
  39. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/offered_models.json +0 -0
  40. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  41. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  42. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  43. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  44. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/outputs.py +0 -0
  45. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/pairings.py +0 -0
  46. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/payers.py +0 -0
  47. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/plugins.py +0 -0
  48. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/policy.py +0 -0
  49. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/qr.py +0 -0
  50. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/receipt.py +0 -0
  51. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/relay.py +0 -0
  52. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/seen.py +0 -0
  53. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/tinfoil_read.py +0 -0
  54. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/tools_probe.py +0 -0
  55. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/uninstall.py +0 -0
  56. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code/winconsole.py +0 -0
  57. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  58. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  59. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/requires.txt +0 -0
  60. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/src/witbitz_code.egg-info/top_level.txt +0 -0
  61. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_asks.py +0 -0
  62. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_attachments.py +0 -0
  63. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_auto.py +0 -0
  64. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_cli.py +0 -0
  65. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_codetools.py +0 -0
  66. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_confidential.py +0 -0
  67. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_connector_e2e.py +0 -0
  68. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_devicelink.py +0 -0
  69. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_files_and_compress.py +0 -0
  70. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_folders.py +0 -0
  71. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_outputs.py +0 -0
  72. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_pair_flows.py +0 -0
  73. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_plugins.py +0 -0
  74. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_policy.py +0 -0
  75. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_registry.py +0 -0
  76. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_relay_codec.py +0 -0
  77. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_tools_probe.py +0 -0
  78. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/tests/test_uninstall.py +0 -0
  79. {witbitz_code-1.3.2 → witbitz_code-1.3.3}/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.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.2"
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.2"
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, 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),
@@ -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
@@ -21,7 +21,7 @@ from dataclasses import dataclass, field
21
21
  from pathlib import Path
22
22
  from typing import Any
23
23
 
24
- from . import codekeys
24
+ from . import codekeys, ports
25
25
  from . import pairings as pp
26
26
  from .connector import _url_port
27
27
 
@@ -74,6 +74,9 @@ class Deps:
74
74
  keys_state: Callable[[dict], Awaitable[str]] | None = None
75
75
  forget_keys: Callable[[dict], Any] = lambda p: codekeys.forget(p.get("computerId") or "")
76
76
  is_listening: Callable[[int], Awaitable[bool]] = None # type: ignore[assignment]
77
+ port_owners: Callable[[int], list] = ports.port_owners # [{pid, cmd}] — this user's processes on a port (ports.py)
78
+ stop_process: Callable[[int], bool] = lambda pid: __import__("witbitz_code.uninstall", fromlist=["stop_process"]).stop_process(pid)
79
+ wsl: bool = field(default_factory=ports.on_wsl)
77
80
  serve_here: Callable[[int], Awaitable[int]] = None # type: ignore[assignment]
78
81
  result: dict = field(default_factory=lambda: {"opencode": False, "paired": False, "keys": False, "running": ""})
79
82
 
@@ -126,7 +129,17 @@ async def run_setup(d: Deps) -> dict:
126
129
  mine = on(everything, port)
127
130
  # A pairing the account has let go of (the owner, 2026-10-08: reinstalled after a removal and "it does not present the
128
131
  # QR code"): its keys come back revoked, so it is not a pairing any more — say so and scan again. Offline is not that.
129
- gone = [p for p in mine if d.keys_state and d.code_keys(p) and await d.keys_state(p) == "revoked"]
132
+ unchecked: dict[str, str] = {} # computerId → why the account could not be asked (said at step 3, never a silent ✓)
133
+ gone = []
134
+ for p in mine:
135
+ if not (d.keys_state and d.code_keys(p)):
136
+ continue
137
+ st = await d.keys_state(p)
138
+ state, why = (st, "") if isinstance(st, str) else (str(st.get("state") or ""), str(st.get("why") or ""))
139
+ if state == "revoked":
140
+ gone.append(p)
141
+ elif state == "unknown":
142
+ unchecked[p.get("computerId") or ""] = why or "no answer"
130
143
  for p in gone:
131
144
  d.forget_keys(p)
132
145
  if mine and len(gone) == len(mine):
@@ -157,13 +170,49 @@ async def run_setup(d: Deps) -> dict:
157
170
  return {**result, "stopped": "keys"}
158
171
  caps = (d.code_keys(mine[0]) or {}).get("caps") or {}
159
172
  d.say(f" ✓ Issued by your Spaces account (this computer: up to ${caps.get('modelsUsd', '?')} of models and ${caps.get('toolsUsd', '?')} of tools a month)")
173
+ for p in mine:
174
+ why = unchecked.get(p.get("computerId") or "")
175
+ if why:
176
+ d.say(f" ? Could not check them with your account just now — {why}. Using them as they are;")
177
+ d.say(" if your phone shows this computer as removed, run: witbitz-code pair")
160
178
  result["keys"] = True
161
179
 
162
180
  # 4 — running (this package has no background service yet: it runs in this window)
163
181
  d.say("\n4. Keep it running")
164
- if await d.is_listening(port):
165
- d.say(f" OpenCode is already running on 127.0.0.1:{port}, started some other way. witbitz-code starts OpenCode itself,")
166
- d.say(" with your account's keys — close that OpenCode first, or serve will stop and say so.")
182
+ # OpenCode's port AND the model proxy's (port + 100): an earlier witbitz-code still running holds both, and keeps answering
183
+ # the phone with the keys it started with (the owner, 2026-10-08: `pipx uninstall` left it running — "could not start
184
+ # the model proxy … address already in use", then "Invalid API key"). This person's own process: offer to stop it.
185
+ for _ in range(6):
186
+ busy = [q for q in (port, port + 100) if await d.is_listening(q)]
187
+ if not busy:
188
+ break
189
+ owners: list[dict] = []
190
+ for q in busy:
191
+ for o in d.port_owners(q):
192
+ if all(o["pid"] != x["pid"] for x in owners):
193
+ owners.append(o)
194
+ if not owners:
195
+ d.say(f" Port {busy[0]} is taken by a program that is not yours{' — on WSL, another Linux distro on this computer shares its ports' if d.wsl else ''}.")
196
+ d.say(f" Run setup with another port: witbitz-code setup --port {port + 1}")
197
+ return {**result, "stopped": "busy"}
198
+ listed = "; ".join(f"{(o.get('cmd') or 'a program')[:70]} (process {o['pid']})" for o in owners)
199
+ d.say(f" Already running here: {listed}.")
200
+ d.say(" That is an earlier witbitz-code or an OpenCode started some other way — it would keep answering your phone with")
201
+ d.say(" other keys. witbitz-code starts OpenCode itself, with this computer's keys from your account.")
202
+ a = str(d.ask(f" Stop {'them' if len(owners) > 1 else 'it'} now? [Y/n] — or type s to stop here: ") or "").strip().lower()
203
+ if a.startswith("s"):
204
+ d.say(" Stopped. Run setup again when you are ready.")
205
+ return {**result, "stopped": "busy"}
206
+ if a.startswith("n"):
207
+ if str(d.ask(" Close it yourself, then press Enter — or type s to stop here: ") or "").strip().lower().startswith("s"):
208
+ d.say(" Stopped. Run setup again when you are ready.")
209
+ return {**result, "stopped": "busy"}
210
+ continue
211
+ for o in owners:
212
+ d.say(f" ✓ Stopped process {o['pid']}" if d.stop_process(o["pid"]) else f" ✖ Could not stop process {o['pid']}")
213
+ else:
214
+ d.say(f" Ports {port} and {port + 100} are still in use. Close what holds them, then run setup again.")
215
+ return {**result, "stopped": "busy"}
167
216
  d.say("\nDone. Starting witbitz-code in this window — keep it open (Ctrl-C stops it). Open Spaces → ☰ → Code on your phone.\n")
168
217
  result["running"] = "here"
169
218
  result["port"] = port
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.2
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`.
@@ -31,6 +31,7 @@ src/witbitz_code/pairings.py
31
31
  src/witbitz_code/payers.py
32
32
  src/witbitz_code/plugins.py
33
33
  src/witbitz_code/policy.py
34
+ src/witbitz_code/ports.py
34
35
  src/witbitz_code/qr.py
35
36
  src/witbitz_code/receipt.py
36
37
  src/witbitz_code/relay.py
@@ -38,6 +39,7 @@ src/witbitz_code/seen.py
38
39
  src/witbitz_code/tinfoil_read.py
39
40
  src/witbitz_code/tools_probe.py
40
41
  src/witbitz_code/uninstall.py
42
+ src/witbitz_code/winagent.py
41
43
  src/witbitz_code/winconsole.py
42
44
  src/witbitz_code/wizard.py
43
45
  src/witbitz_code.egg-info/PKG-INFO
@@ -65,9 +67,11 @@ tests/test_outputs.py
65
67
  tests/test_pair_flows.py
66
68
  tests/test_plugins.py
67
69
  tests/test_policy.py
70
+ tests/test_ports.py
68
71
  tests/test_registry.py
69
72
  tests/test_relay_codec.py
70
73
  tests/test_setup.py
71
74
  tests/test_tools_probe.py
72
75
  tests/test_uninstall.py
76
+ tests/test_winagent.py
73
77
  tests/test_winconsole.py
@@ -142,6 +142,121 @@ def test_live_keys_follow_what_the_server_says():
142
142
  assert live.credential("cmp_1") == {"refuse": codekeys.REVOKED_TEXT} and codekeys.for_computer("cmp_1") is None, "revoked: forgotten here too"
143
143
 
144
144
 
145
+
146
+ # ── 1.3.3: the laptop's clock, a refusal that says why, keys that change under a running serve ──────────────────────────
147
+ # The owner, 2026-10-08, on WSL: setup said "✓ Issued" over keys the account had taken back, a running serve answered with
148
+ # them ("Invalid API key"), and a re-pair's keys never reached it. A WSL clock falls behind while the laptop sleeps, and the
149
+ # server refuses a signed call more than 5 minutes off — so the answer never came, and nothing said why.
150
+ def _server(skew_s: float, answer: dict, calls: list):
151
+ """A /status + /revoke that keeps its own clock (local + skew) and refuses a call more than 300 s off, as codeKeys.mjs."""
152
+ from email.utils import formatdate
153
+ import time as _t
154
+
155
+ def handler(req):
156
+ body = json.loads(req.content or b"{}")
157
+ calls.append((req.url.path, body.get("ts")))
158
+ now = _t.time() + skew_s
159
+ date = {"date": formatdate(now, usegmt=True)}
160
+ if abs(int(body.get("ts", 0)) - now) > 300:
161
+ return httpx.Response(401, headers=date, json={"error": {"code": "unauthorized", "message": "the request is too old or from the future — check this computer's clock"}})
162
+ return httpx.Response(200, headers=date, json={"data": answer})
163
+ return handler
164
+
165
+
166
+ def test_a_laptop_whose_clock_is_off_still_gets_the_answer_and_signs_to_the_servers_time_after():
167
+ from fakes import give_code_keys
168
+
169
+ codekeys.reset_clock()
170
+ e = give_code_keys("cmp_1")
171
+ calls = []
172
+ async def go():
173
+ async with client_for(_server(-3600, {"live": False, "why": "revoked"}, calls)) as c: # this computer is an hour AHEAD
174
+ first = await codekeys.status(c, e)
175
+ second = await codekeys.status(c, e)
176
+ gone = await codekeys.revoke(c, e)
177
+ return first, second, gone
178
+ first, second, gone = run(go())
179
+ assert first == {"live": False, "why": "revoked"} and second == first and gone is True
180
+ assert len(calls) == 4, f"one refused call, then every call signed to the server's clock: {calls}"
181
+ codekeys.reset_clock()
182
+
183
+
184
+ def test_an_answer_that_cannot_be_had_says_why():
185
+ from fakes import give_code_keys
186
+
187
+ codekeys.reset_clock()
188
+ e = give_code_keys("cmp_1")
189
+ def refuse(req):
190
+ return httpx.Response(401, json={"error": {"code": "unauthorized", "message": "the signature does not match this computer"}})
191
+ async def go():
192
+ async with client_for(refuse) as c:
193
+ return await codekeys.status(c, e)
194
+ st = run(go())
195
+ assert st["live"] is None and "signature does not match" in st["why"]
196
+
197
+
198
+ def _replace_keys(computer_id: str, kid: str, tr: str) -> dict:
199
+ from fakes import give_code_keys
200
+
201
+ e = give_code_keys(computer_id)
202
+ e = {**e, "keyId": e["userId"] + "." + kid, "kid": kid, "trustedrouter": tr}
203
+ codekeys.save(e)
204
+ return e
205
+
206
+
207
+ def test_a_running_serve_picks_up_the_keys_a_new_pairing_saved():
208
+ from fakes import give_code_keys
209
+
210
+ give_code_keys("cmp_1")
211
+ live = codekeys.LiveKeys(["cmp_1"])
212
+ assert live.credential("cmp_1")["trustedrouter"] == "sk-tr-made-up-cmp_1"
213
+ _replace_keys("cmp_1", "k_" + "2" * 24, "sk-tr-the-new-one")
214
+ assert live.credential("cmp_1")["trustedrouter"] == "sk-tr-the-new-one", "the new pairing's key, without a restart"
215
+
216
+
217
+ def test_the_old_keys_revocation_never_forgets_the_new_pairings_keys():
218
+ from fakes import give_code_keys
219
+
220
+ give_code_keys("cmp_1")
221
+ live = codekeys.LiveKeys(["cmp_1"])
222
+ landed = {}
223
+ def answer(req): # the re-pair lands while the check is asking about the OLD keys — the server took those back
224
+ landed.setdefault("new", _replace_keys("cmp_1", "k_" + "2" * 24, "sk-tr-the-new-one"))
225
+ return httpx.Response(200, json={"data": {"live": False, "why": "revoked"}})
226
+ async def check():
227
+ async with client_for(answer) as c:
228
+ await live.check(c, lambda _m: None)
229
+ run(check())
230
+ new = landed["new"]
231
+ assert (codekeys.for_computer("cmp_1") or {}).get("keyId") == new["keyId"], "the new pairing's keys are still on disk"
232
+ assert live.credential("cmp_1")["trustedrouter"] == "sk-tr-the-new-one"
233
+
234
+
235
+
236
+ def test_a_key_trustedrouter_refuses_makes_the_proxy_say_so_at_once_for_both_kinds_of_model():
237
+ # a removal in the app (or a re-pair) takes the keys back; serve asked the account only every few hours, so the phone
238
+ # read TrustedRouter's "Invalid API key" until then. Now the proxy tells serve, which asks the account at once.
239
+ told = []
240
+ def upstream(req):
241
+ return httpx.Response(401, json={"error": {"message": "Invalid API key"}})
242
+ async def gate_ok(): # TrustedRouter's gateway attestation is its own test (test_confidential.py)
243
+ return {"ok": True}
244
+ async def go(model):
245
+ p = await cf.start_confidential_proxy(upstream="https://tr.test", client=httpx.AsyncClient(transport=httpx.MockTransport(upstream)),
246
+ reader=None, log=lambda _l: None, credential_for=lambda req: {"trustedrouter": "sk-old"},
247
+ on_key_refused=lambda: told.append(model), gate=gate_ok)
248
+ try:
249
+ async with httpx.AsyncClient(timeout=10) as oc:
250
+ r = await oc.post(f"http://127.0.0.1:{p.port}/v1/chat/completions", json={"model": model, "messages": []}, headers={"x-session-id": "s"})
251
+ return r.status_code
252
+ finally:
253
+ await p.close()
254
+ plain = run(go("openai/gpt-6-sol"))
255
+ conf_model = next(iter(mdl.confidential_models()))
256
+ conf = run(go(conf_model))
257
+ assert plain == 401 and conf == 401 and told == ["openai/gpt-6-sol", conf_model]
258
+
259
+
145
260
  # ── who pays ─────────────────────────────────────────────────────────────────────────────────────────────────────────
146
261
  def test_each_turn_is_its_accounts_and_another_account_cannot_steer_into_it():
147
262
  p = SessionPayers()
@@ -0,0 +1,31 @@
1
+ """ports.port_owners against a real listening process (setup's step 4 asks it before offering to stop an earlier run)."""
2
+
3
+ import socket
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from witbitz_code import ports
12
+
13
+ pytestmark = pytest.mark.skipif(not Path("/proc/net/tcp").exists() and sys.platform != "darwin", reason="Linux /proc or macOS lsof")
14
+
15
+
16
+ def test_the_process_listening_on_a_port_is_named_and_a_free_port_has_nobody():
17
+ with socket.socket() as s:
18
+ s.bind(("127.0.0.1", 0))
19
+ port = s.getsockname()[1]
20
+ child = subprocess.Popen([sys.executable, "-c", f"import socket,time;s=socket.socket();s.bind(('127.0.0.1',{port}));s.listen();time.sleep(30)"])
21
+ try:
22
+ for _ in range(50):
23
+ hits = ports.port_owners(port)
24
+ if hits:
25
+ break
26
+ time.sleep(0.1)
27
+ assert [h["pid"] for h in hits] == [child.pid] and "socket" in hits[0]["cmd"]
28
+ finally:
29
+ child.kill()
30
+ child.wait()
31
+ assert ports.port_owners(port) == []
@@ -118,7 +118,9 @@ class Fake:
118
118
  find_opencode=lambda: "/usr/bin/opencode" if self._oc else "", can_install_opencode=lambda: False,
119
119
  all_pairings=lambda: list(self._pairings), pair=pair, move_pairing=lambda p, port: None,
120
120
  code_keys=lambda p: {"caps": {"modelsUsd": 50, "toolsUsd": 5}} if p.get("computerId") in self._keyed else None,
121
- is_listening=listening, serve_here=serve_here, **over)
121
+ serve_here=serve_here,
122
+ # never this machine's real processes: the real lookup finds whatever listens on 4096 HERE (an OpenCode does)
123
+ **{"is_listening": listening, "port_owners": lambda port: [], "stop_process": lambda pid: False, "wsl": False, **over})
122
124
 
123
125
 
124
126
  def test_a_new_computer_is_paired_gets_its_keys_from_the_account_and_is_served_without_a_key_prompt():
@@ -162,11 +164,38 @@ def test_paired_for_another_port_uses_it():
162
164
  assert r["port"] == 4097 and f.served == [4097] and f.paired == []
163
165
 
164
166
 
165
- def test_an_opencode_started_elsewhere_is_named():
167
+ def test_a_port_taken_by_a_program_that_is_not_yours_stops_setup_with_another_port_to_use():
166
168
  f = Fake(listening=True)
167
- run(wizard.run_setup(f.deps()))
168
- assert any("started some other way" in line for line in f.said)
169
- assert any("close that OpenCode first" in line for line in f.said)
169
+ r = run(wizard.run_setup(f.deps()))
170
+ assert r["stopped"] == "busy" and f.served == []
171
+ assert any("Port 4096 is taken by a program that is not yours" in line for line in f.said)
172
+ assert any("witbitz-code setup --port 4097" in line for line in f.said)
173
+
174
+
175
+ def test_an_earlier_witbitz_code_still_running_is_named_and_stopped_both_ports_then_this_one_serves():
176
+ # the owner, 2026-10-08: `pipx uninstall` left the old serve running — its OpenCode on 4096, its proxy on 4196
177
+ held = {4096: {"pid": 11, "cmd": "opencode serve --port 4096"}, 4196: {"pid": 10, "cmd": "/usr/bin/python3 witbitz-code serve"}}
178
+ f = Fake(pairings=[{"name": "desk", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}], keyed={"c"}, answers=[""])
179
+ stopped = []
180
+ async def listening(port):
181
+ return port in held
182
+ def stop(pid):
183
+ stopped.append(pid)
184
+ for q in [q for q, o in held.items() if o["pid"] == pid]:
185
+ held.pop(q)
186
+ return True
187
+ r = run(wizard.run_setup(f.deps(is_listening=listening, port_owners=lambda port: [held[port]] if port in held else [], stop_process=stop)))
188
+ assert sorted(stopped) == [10, 11] and f.served == [4096] and not r.get("stopped")
189
+ assert any("Already running here: opencode serve --port 4096 (process 11); /usr/bin/python3 witbitz-code serve (process 10)" in line for line in f.said)
190
+
191
+
192
+ def test_s_stops_setup_and_leaves_the_running_one_alone():
193
+ held = {4096: {"pid": 11, "cmd": "opencode serve"}}
194
+ f = Fake(answers=["s"])
195
+ async def listening(port):
196
+ return port in held
197
+ r = run(wizard.run_setup(f.deps(is_listening=listening, port_owners=lambda port: [held[port]] if port in held else [], stop_process=lambda pid: (_ for _ in ()).throw(AssertionError("never")))))
198
+ assert r["stopped"] == "busy" and f.served == []
170
199
 
171
200
 
172
201
  def test_a_computer_removed_from_the_account_is_paired_again_and_shows_the_qr():
@@ -184,9 +213,10 @@ def test_a_computer_removed_from_the_account_is_paired_again_and_shows_the_qr():
184
213
  assert not any(line.startswith(" ✓ Paired") for line in f.said[:f.said.index(next(x for x in f.said if "was removed" in x))])
185
214
 
186
215
 
187
- def test_keys_that_cannot_be_checked_are_not_taken_for_a_removal():
216
+ def test_keys_that_cannot_be_checked_are_not_taken_for_a_removal_and_setup_says_why():
188
217
  f = Fake(pairings=[{"name": "desk", "opencodeUrl": "http://127.0.0.1:4096", "idx": {"room": "r"}, "computerId": "c"}], keyed={"c"})
189
218
  async def keys_state(p):
190
- return "unknown"
219
+ return {"state": "unknown", "why": "the signature does not match this computer (401)"}
191
220
  r = run(wizard.run_setup(f.deps(keys_state=keys_state, forget_keys=lambda p: f._keyed.discard(p["computerId"]))))
192
221
  assert f.paired == [] and r["keys"] and f.served == [4096]
222
+ assert any("Could not check them with your account just now — the signature does not match this computer (401)" in line for line in f.said)
@@ -0,0 +1,69 @@
1
+ """What OpenCode gets on Windows (benchmark 2026-10-08, Windows 11, OpenCode 1.18.30, six teacher tasks with Hebrew data,
2
+ DeepSeek V4.1 Flash / Claude Haiku 4.5 / GLM 5.3 Flash): PYTHONUTF8=1 (without it Windows Python crashed printing Hebrew),
3
+ Git Bash as the shell (a model's bash habits just work there), a Python of its own first on PATH with a real python3 (the
4
+ bare name is the Microsoft Store stub), and an instructions file naming all that — 48/48 correct with them, 45/48 without;
5
+ Haiku on Git Bash 68 → 3 failed commands."""
6
+
7
+ from pathlib import Path
8
+
9
+ from witbitz_code import winagent
10
+
11
+ LOCAL = r"C:\Users\t\AppData\Local"
12
+ ENV = {"LOCALAPPDATA": LOCAL, "ProgramFiles": r"C:\Program Files", "PATH": r"C:\Windows\system32"}
13
+
14
+
15
+ def exists_of(*paths):
16
+ have = {p.lower() for p in paths}
17
+ return lambda p: str(p).lower() in have
18
+
19
+
20
+ def test_nothing_changes_off_windows(tmp_path):
21
+ cfg = tmp_path / "opencode" # its own folder: conftest makes tmp_path/home for every test
22
+ add, instr, note = winagent.windows_agent(cfg, env=ENV, platform="linux", exists=exists_of())
23
+ assert (add, instr, note) == ({}, None, "")
24
+ assert not cfg.exists(), "no file written off Windows"
25
+
26
+
27
+ def test_git_bash_python_and_utf8_on_windows(tmp_path):
28
+ bash = r"C:\Program Files\Git\bin\bash.exe"
29
+ py = LOCAL + r"\Witbitz\agent-python\Scripts\python.exe"
30
+ add, instr, note = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(bash, py))
31
+ assert add["PYTHONUTF8"] == "1"
32
+ assert add["SHELL"] == bash
33
+ assert add["PATH_PREPEND"] == [LOCAL + r"\Witbitz\agent-python\Scripts"]
34
+ text = Path(instr).read_text(encoding="utf-8")
35
+ assert "Git Bash" in text and r"C:\Users" in text and "/c/" in text, "the read/write tools need C:\\ paths, never /c/"
36
+ assert "Git Bash" in note and "agent-python" in note
37
+
38
+
39
+ def test_witbitz_own_git_wins_over_none_and_powershell_is_the_fallback(tmp_path):
40
+ own = LOCAL + r"\Witbitz\git\bin\bash.exe"
41
+ add, _, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(own))
42
+ assert add["SHELL"] == own
43
+ add, instr, note = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of())
44
+ assert "SHELL" not in add and "PATH_PREPEND" not in add and add["PYTHONUTF8"] == "1"
45
+ text = Path(instr).read_text(encoding="utf-8")
46
+ assert "PowerShell" in text and "-Encoding UTF8" in text and "<<" in text
47
+ assert "no Python" in note
48
+
49
+
50
+ def test_the_instructions_file_is_rewritten_only_when_it_changes(tmp_path):
51
+ _, instr, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of())
52
+ p = Path(instr)
53
+ before = p.stat().st_mtime_ns
54
+ winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of())
55
+ assert p.stat().st_mtime_ns == before
56
+
57
+
58
+ def test_apply_puts_python_ahead_of_ast_grep_and_adds_to_the_instructions():
59
+ tools_env = {"PATH": r"C:\ast;C:\Windows\system32", "OPENCODE_EXPERIMENTAL_LSP_TOOL": "true"}
60
+ tools_config = {"instructions": [r"C:\cfg\witbitz-tools.md"]}
61
+ add = {"PYTHONUTF8": "1", "SHELL": r"C:\Git\bin\bash.exe", "PATH_PREPEND": [r"C:\py\Scripts"]}
62
+ env, config = winagent.apply(tools_env, tools_config, add, r"C:\cfg\witbitz-windows.md", base_path=r"C:\Windows\system32")
63
+ assert env["PATH"] == r"C:\py\Scripts;C:\ast;C:\Windows\system32"
64
+ assert env["SHELL"] == r"C:\Git\bin\bash.exe" and env["PYTHONUTF8"] == "1" and "PATH_PREPEND" not in env
65
+ assert config["instructions"] == [r"C:\cfg\witbitz-tools.md", r"C:\cfg\witbitz-windows.md"]
66
+ assert tools_config["instructions"] == [r"C:\cfg\witbitz-tools.md"], "the caller's config is not changed"
67
+ # no ast-grep: PATH starts from the base
68
+ env, config = winagent.apply({}, {}, add, None, base_path=r"C:\Windows\system32")
69
+ assert env["PATH"] == r"C:\py\Scripts;C:\Windows\system32" and config == {}
File without changes
File without changes