witbitz-code 1.3.6__tar.gz → 1.3.7__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 (84) hide show
  1. {witbitz_code-1.3.6/src/witbitz_code.egg-info → witbitz_code-1.3.7}/PKG-INFO +1 -1
  2. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/pyproject.toml +1 -1
  3. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/cli.py +5 -0
  5. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/ports.py +53 -1
  6. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/tools_probe.py +26 -4
  7. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/uninstall.py +37 -2
  8. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/winagent.py +6 -1
  9. {witbitz_code-1.3.6 → witbitz_code-1.3.7/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  10. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/SOURCES.txt +1 -0
  11. witbitz_code-1.3.7/tests/test_ports_win.py +83 -0
  12. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_tools_probe.py +28 -0
  13. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_winagent.py +11 -0
  14. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/LICENSE +0 -0
  15. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/README.md +0 -0
  16. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/setup.cfg +0 -0
  17. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/__main__.py +0 -0
  18. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/_js.py +0 -0
  19. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/account.py +0 -0
  20. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/asks.py +0 -0
  21. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/attachments.py +0 -0
  22. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/auto.py +0 -0
  23. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/auto_runner.py +0 -0
  24. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/codekeys.py +0 -0
  25. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/codetools.py +0 -0
  26. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/compress.py +0 -0
  27. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/computers.py +0 -0
  28. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/confidential.py +0 -0
  29. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/confidential_models.json +0 -0
  30. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/connector.py +0 -0
  31. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/devicelink.py +0 -0
  32. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/folders.py +0 -0
  33. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/gateway.py +0 -0
  34. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/keys.py +0 -0
  35. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/link.py +0 -0
  36. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/models.py +0 -0
  37. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/net.py +0 -0
  38. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/offered_models.json +0 -0
  39. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  40. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  41. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  42. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  43. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/outputs.py +0 -0
  44. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/pair.py +0 -0
  45. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/pairings.py +0 -0
  46. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/payers.py +0 -0
  47. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/plugins.py +0 -0
  48. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/policy.py +0 -0
  49. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/qr.py +0 -0
  50. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/receipt.py +0 -0
  51. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/relay.py +0 -0
  52. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/seen.py +0 -0
  53. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/tinfoil_read.py +0 -0
  54. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/tools_catalog.json +0 -0
  55. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/toolsetup.py +0 -0
  56. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/winconsole.py +0 -0
  57. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/wizard.py +0 -0
  58. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  59. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  60. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/requires.txt +0 -0
  61. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/top_level.txt +0 -0
  62. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_asks.py +0 -0
  63. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_attachments.py +0 -0
  64. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_auto.py +0 -0
  65. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_cli.py +0 -0
  66. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_codekeys.py +0 -0
  67. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_codetools.py +0 -0
  68. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_confidential.py +0 -0
  69. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_connector_e2e.py +0 -0
  70. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_devicelink.py +0 -0
  71. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_files_and_compress.py +0 -0
  72. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_folders.py +0 -0
  73. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_outputs.py +0 -0
  74. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_pair_flows.py +0 -0
  75. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_plugins.py +0 -0
  76. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_policy.py +0 -0
  77. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_ports.py +0 -0
  78. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_registry.py +0 -0
  79. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_relay_codec.py +0 -0
  80. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_setup.py +0 -0
  81. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_toolsetup.py +0 -0
  82. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_uninstall.py +0 -0
  83. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_winconsole.py +0 -0
  84. {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_windows_files.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.6
3
+ Version: 1.3.7
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.3.6"
7
+ version = "1.3.7"
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.6"
7
+ __version__ = "1.3.7"
@@ -161,6 +161,11 @@ async def serve(args: list[str]) -> int:
161
161
  if not pairings:
162
162
  _err(f"witbitz-code: no pairing uses OpenCode on port {port} — pair with: witbitz-code pair --port {port}")
163
163
  return 1
164
+ # Windows: a tool installed since this window opened (setup's Tools step, winget) is on the registry's PATH, not yet on
165
+ # this process's — read it, so the agent's OpenCode finds it (= witbitz-code.mjs serve)
166
+ reg = toolsetup.registry_path()
167
+ if reg:
168
+ os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
164
169
  # Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
165
170
  # written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
166
171
  # running picks them up at its next restart.
@@ -3,19 +3,25 @@
3
3
  setup asks it before starting: an earlier witbitz-code still running (`pipx uninstall` does not stop it) holds OpenCode's
4
4
  port and the model proxy's (port + 100), and keeps answering the phone with the keys it started with (the owner,
5
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.
6
+ asks lsof; Windows asks netstat and tasklist (_win_owners, = tools/code-ports-win.mjs — before 1.3.7 Windows knew nobody and
7
+ setup called the person's own `witbitz-code serve` "a program that is not yours"); elsewhere nothing is known.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
10
11
 
11
12
  import os
12
13
  import shutil
14
+ import re
13
15
  import subprocess
16
+ import sys
17
+ from collections.abc import Callable
14
18
  from pathlib import Path
15
19
 
16
20
 
17
21
  def port_owners(port: int) -> list[dict]:
18
22
  """[{pid, cmd}] of this user's processes listening on 127.0.0.1/::1 `port` (never this process)."""
23
+ if sys.platform == "win32":
24
+ return win_owners(port)
19
25
  if Path("/proc/net/tcp").exists():
20
26
  return _proc_owners(port)
21
27
  if shutil.which("lsof"):
@@ -85,3 +91,49 @@ def on_wsl() -> bool:
85
91
  return "microsoft" in Path("/proc/version").read_text().lower()
86
92
  except OSError:
87
93
  return False
94
+
95
+
96
+ def parse_netstat_listeners(text: str, port: int) -> list[int]:
97
+ """The process ids listening on TCP `port`, from `netstat -ano` output. A listening socket's foreign address is 0.0.0.0:0
98
+ or [::]:0 — the State column is translated on some Windows languages, the addresses are not."""
99
+ pids: set[int] = set()
100
+ for line in (text or "").splitlines():
101
+ c = line.split()
102
+ if len(c) < 5 or c[0].upper() != "TCP" or not c[1].endswith(f":{port}") or c[2] not in ("0.0.0.0:0", "[::]:0"):
103
+ continue
104
+ if c[-1].isdigit() and int(c[-1]) > 0:
105
+ pids.add(int(c[-1]))
106
+ return sorted(pids)
107
+
108
+
109
+ def parse_tasklist_row(text: str) -> dict | None:
110
+ """One CSV row of `tasklist /V /FO CSV /NH` → {image, pid, user}, or None."""
111
+ line = next((ln for ln in (text or "").splitlines() if ln.strip().startswith('"')), None)
112
+ if line is None:
113
+ return None
114
+ cells = [m.replace('""', '"') for m in re.findall(r'"((?:[^"]|"")*)"', line)]
115
+ if len(cells) < 7 or not cells[1].isdigit():
116
+ return None
117
+ return {"image": cells[0], "pid": int(cells[1]), "user": cells[6]}
118
+
119
+
120
+ def _run_text(argv: list[str]) -> str:
121
+ try:
122
+ return subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=15).stdout or ""
123
+ except (OSError, subprocess.SubprocessError):
124
+ return ""
125
+
126
+
127
+ def win_owners(port: int, *, run: Callable[[list[str]], str] = _run_text, user: str | None = None, self_pid: int | None = None) -> list[dict]:
128
+ """[{pid, cmd}] of this user's processes listening on `port` on Windows: netstat for the ids, tasklist for each one's
129
+ program and user (another user's process says N/A and is left out)."""
130
+ user = os.environ.get("USERNAME", "") if user is None else user
131
+ self_pid = os.getpid() if self_pid is None else self_pid
132
+ hits = []
133
+ for pid in parse_netstat_listeners(run(["netstat", "-ano"]), port):
134
+ if pid == self_pid:
135
+ continue
136
+ row = parse_tasklist_row(run(["tasklist", "/V", "/FO", "CSV", "/NH", "/FI", f"PID eq {pid}"]))
137
+ if row and row["pid"] == pid and (not user or row["user"].rsplit("\\", 1)[-1].lower() == user.lower()):
138
+ hits.append({"pid": pid, "cmd": row["image"]})
139
+ return hits
@@ -30,6 +30,28 @@ _EXTRA_HOME = [".local/bin", ".cargo/bin", "bin"]
30
30
  # Windows: under %LOCALAPPDATA%\Witbitz — what the Windows installer (code.ps1) fills and witbitz-code serve gives the agent:
31
31
  # its Git (Git Bash, the agent's shell), its own Python, and what setup's Tools step puts there (Poppler). = code-tools-probe.mjs
32
32
  _WIN_WITBITZ = ["bin", "git\\cmd", "git\\bin", "agent-python\\Scripts", "poppler\\bin"]
33
+ # Windows: tools whose installers do not add themselves to PATH (measured 2026-10-09 on the owner's laptop: winget installed
34
+ # Tesseract (UB-Mannheim) and qpdf, setup's re-check still found neither) — their usual folders. = code-tools-probe.mjs
35
+ _WIN_PROGRAM_ROOTS = ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"]
36
+
37
+
38
+ def _listdir(d: str) -> list[str]:
39
+ try:
40
+ return os.listdir(d)
41
+ except OSError:
42
+ return []
43
+
44
+
45
+ def win_program_dirs(env: Mapping[str, str], listdir: Callable[[str], list[str]] = _listdir) -> list[str]:
46
+ """Tesseract-OCR and qpdf <version>\\bin under Program Files (all three) and %LOCALAPPDATA%\\Programs."""
47
+ roots = [(env.get(k) or "").rstrip("\\/") for k in _WIN_PROGRAM_ROOTS]
48
+ local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
49
+ roots = list(dict.fromkeys([r for r in roots + ([f"{local}\\Programs"] if local else []) if r]))
50
+ out: list[str] = []
51
+ for r in roots:
52
+ out.append(f"{r}\\Tesseract-OCR")
53
+ out += [f"{r}\\{n}\\bin" for n in sorted(listdir(r)) if n.lower().startswith("qpdf")]
54
+ return out
33
55
 
34
56
 
35
57
  def _is_executable(path: str) -> bool:
@@ -40,26 +62,26 @@ def _is_executable(path: str) -> bool:
40
62
  return stat.S_ISREG(st.st_mode) and os.access(path, os.X_OK)
41
63
 
42
64
 
43
- def search_dirs(*, env: Mapping[str, str], platform: str, home: str) -> list[str]:
65
+ def search_dirs(*, env: Mapping[str, str], platform: str, home: str, listdir: Callable[[str], list[str]] = _listdir) -> list[str]:
44
66
  """The folders searched, in order, without repeats."""
45
67
  win = platform == "win32"
46
68
  raw = env.get("PATH") or env.get("Path") or ""
47
69
  from_path = [d for d in raw.split(";" if win else ":") if d]
48
70
  if win:
49
71
  local = (env.get("LOCALAPPDATA") or "").rstrip("\\/")
50
- extra = [f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []
72
+ extra = ([f"{local}\\Witbitz\\{d}" for d in _WIN_WITBITZ] if local else []) + win_program_dirs(env, listdir)
51
73
  else:
52
74
  extra = _EXTRA_POSIX + ([f"{home.rstrip('/')}/{d}" for d in _EXTRA_HOME] if home else [])
53
75
  return list(dict.fromkeys(from_path + extra))
54
76
 
55
77
 
56
78
  def probe_tools(*, env: Mapping[str, str] | None = None, platform: str | None = None, home: str | None = None,
57
- is_executable: Callable[[str], bool] = _is_executable) -> dict:
79
+ is_executable: Callable[[str], bool] = _is_executable, listdir: Callable[[str], list[str]] = _listdir) -> dict:
58
80
  env = os.environ if env is None else env
59
81
  platform = sys.platform if platform is None else platform
60
82
  home = str(Path.home()) if home is None else home
61
83
  win = platform == "win32"
62
- dirs = search_dirs(env=env, platform=platform, home=home)
84
+ dirs = search_dirs(env=env, platform=platform, home=home, listdir=listdir)
63
85
  exts = [""] + [e.lower() for e in (env.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD").split(";") if e] if win else [""]
64
86
 
65
87
  def join(d: str, b: str) -> str:
@@ -150,8 +150,43 @@ def opencode_holders(data_dir: str) -> list[dict]:
150
150
  return hits
151
151
 
152
152
 
153
+ def _win_alive(pid: int) -> bool:
154
+ """Windows: is `pid` still running? (OpenProcess + GetExitCodeProcess; access denied = another user's, still running.)"""
155
+ import ctypes # noqa: PLC0415 — Windows only
156
+
157
+ k = ctypes.windll.kernel32 # type: ignore[attr-defined]
158
+ h = k.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
159
+ if not h:
160
+ return k.GetLastError() == 5 # ERROR_ACCESS_DENIED
161
+ try:
162
+ code = ctypes.c_ulong()
163
+ return bool(k.GetExitCodeProcess(h, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
164
+ finally:
165
+ k.CloseHandle(h)
166
+
167
+
168
+ def win_stop_process(pid: int, *, run: Callable[[list[str]], Any] | None = None, alive: Callable[[int], bool] = _win_alive,
169
+ sleep: Callable[[float], None] = time.sleep) -> bool:
170
+ """Windows: taskkill /T /F (the process and what it started — a witbitz-code serve's OpenCode goes with it), then wait up
171
+ to 5 s for it to be gone. Never os.kill(pid, 0): on Windows signal 0 is CTRL_C_EVENT, a Ctrl-C to the console's
172
+ processes — setup's own window included — not a probe."""
173
+ run = run or (lambda argv: subprocess.run(argv, capture_output=True, check=False, timeout=30))
174
+ try:
175
+ run(["taskkill", "/PID", str(pid), "/T", "/F"])
176
+ except (OSError, subprocess.SubprocessError):
177
+ pass
178
+ for _ in range(50):
179
+ if not alive(pid):
180
+ return True
181
+ sleep(0.1)
182
+ return not alive(pid)
183
+
184
+
153
185
  def stop_process(pid: int) -> bool:
154
- """SIGTERM, up to 5 s, then SIGKILL. Gone = no such process, or a zombie (exited, not yet reaped)."""
186
+ """SIGTERM, up to 5 s, then SIGKILL. Gone = no such process, or a zombie (exited, not yet reaped). Windows: win_stop_process."""
187
+ if sys.platform == "win32":
188
+ return win_stop_process(pid)
189
+
155
190
  def gone() -> bool:
156
191
  try:
157
192
  os.kill(pid, 0)
@@ -170,7 +205,7 @@ def stop_process(pid: int) -> bool:
170
205
  if gone():
171
206
  return True
172
207
  try:
173
- os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM)) # Windows has no SIGKILL
208
+ os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
174
209
  except OSError:
175
210
  pass
176
211
  time.sleep(0.3)
@@ -19,6 +19,8 @@ import sys
19
19
  from pathlib import Path
20
20
  from typing import Callable
21
21
 
22
+ from . import tools_probe
23
+
22
24
  INSTRUCTIONS_NAME = "witbitz-windows.md"
23
25
 
24
26
  GIT_BASH = """# This computer
@@ -70,7 +72,7 @@ def _write_once(f: Path, text: str) -> None:
70
72
 
71
73
 
72
74
  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]:
75
+ exists: Callable[[str], bool] = os.path.exists, listdir: Callable[[str], list] | None = None) -> tuple[dict, str | None, str]:
74
76
  """→ (env additions — PATH_PREPEND is a list for apply(), the instructions file or None, a line for the log or "")."""
75
77
  platform = sys.platform if platform is None else platform
76
78
  if platform != "win32":
@@ -84,6 +86,9 @@ def windows_agent(config_dir: Path, env: dict | None = None, platform: str | Non
84
86
  # Poppler from setup's Tools step (toolsetup.py: winget has no package, so a pinned release into Witbitz\poppler\bin)
85
87
  poppler = ntpath.join(_witbitz_dir(env), "poppler", "bin")
86
88
  prepend = [d for d in (py, poppler if exists(ntpath.join(poppler, "pdftotext.exe")) else None) if d]
89
+ # Tesseract and qpdf: their installers leave PATH alone (tools_probe.win_program_dirs — setup's probe looks there too)
90
+ programs = tools_probe.win_program_dirs(env, listdir) if listdir else tools_probe.win_program_dirs(env)
91
+ prepend += [d for d in programs if exists(ntpath.join(d, "tesseract.exe")) or exists(ntpath.join(d, "qpdf.exe"))]
87
92
  if prepend:
88
93
  add["PATH_PREPEND"] = prepend
89
94
  f = Path(config_dir) / INSTRUCTIONS_NAME
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.3.6
3
+ Version: 1.3.7
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
@@ -70,6 +70,7 @@ tests/test_pair_flows.py
70
70
  tests/test_plugins.py
71
71
  tests/test_policy.py
72
72
  tests/test_ports.py
73
+ tests/test_ports_win.py
73
74
  tests/test_registry.py
74
75
  tests/test_relay_codec.py
75
76
  tests/test_setup.py
@@ -0,0 +1,83 @@
1
+ """Windows: who holds a port (netstat + tasklist) and stopping it without os.kill(pid, 0) — measured on the owner's laptop,
2
+ 2026-10-09: setup called their own `witbitz-code serve` "a program that is not yours" (Windows knew nobody)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+
9
+ from conftest import requires_node, run_node
10
+
11
+ from witbitz_code import ports, uninstall
12
+
13
+ NETSTAT = """
14
+ Active Connections
15
+
16
+ Proto Local Address Foreign Address State PID
17
+ TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1100
18
+ TCP 127.0.0.1:4096 0.0.0.0:0 ABHÖREN 7001
19
+ TCP 127.0.0.1:4096 127.0.0.1:52011 ESTABLISHED 7001
20
+ TCP 127.0.0.1:52011 127.0.0.1:4096 ESTABLISHED 9999
21
+ TCP 127.0.0.1:4196 0.0.0.0:0 LISTENING 7002
22
+ TCP [::1]:4096 [::]:0 LISTENING 7003
23
+ TCP 127.0.0.1:40960 0.0.0.0:0 LISTENING 7004
24
+ UDP 0.0.0.0:4096 *:* 7005
25
+ """
26
+
27
+ TASKLIST = {
28
+ 7001: '"opencode.exe","7001","Console","1","120,404 K","Running","LAPTOP\\shter","0:00:03","N/A"',
29
+ 7002: '"python.exe","7002","Console","1","80,000 K","Running","LAPTOP\\Shter","0:00:01","witbitz-code serve"',
30
+ 7003: '"other.exe","7003","Services","0","9,000 K","Unknown","N/A","0:00:00","N/A"',
31
+ }
32
+
33
+
34
+ def fake_run(argv: list[str]) -> str:
35
+ if argv[0] == "netstat":
36
+ return NETSTAT
37
+ pid = int(argv[-1].split()[-1])
38
+ return TASKLIST.get(pid, "INFO: No tasks are running which match the specified criteria.")
39
+
40
+
41
+ def test_the_listening_processes_are_read_by_address_not_by_the_translated_state():
42
+ assert ports.parse_netstat_listeners(NETSTAT, 4096) == [7001, 7003]
43
+ assert ports.parse_netstat_listeners(NETSTAT, 4196) == [7002]
44
+ assert ports.parse_netstat_listeners(NETSTAT, 52011) == [], "a connection's port is not a listener"
45
+
46
+
47
+ def test_only_this_users_programs_are_named_and_never_this_process():
48
+ assert ports.win_owners(4096, run=fake_run, user="shter", self_pid=1) == [{"pid": 7001, "cmd": "opencode.exe"}]
49
+ assert ports.win_owners(4196, run=fake_run, user="shter", self_pid=1) == [{"pid": 7002, "cmd": "python.exe"}]
50
+ assert ports.win_owners(4196, run=fake_run, user="shter", self_pid=7002) == []
51
+ assert ports.parse_tasklist_row("INFO: No tasks are running which match the specified criteria.") is None
52
+
53
+
54
+ @requires_node
55
+ def test_the_javascript_reads_the_same():
56
+ js = run_node("""
57
+ import { parseNetstatListeners, winPortOwners } from '@TOOLS@/code-ports-win.mjs'
58
+ const run = (argv) => argv[0] === 'netstat' ? INPUT.netstat : (INPUT.tasklist[argv[argv.length - 1].split(' ').pop()] || 'INFO: none')
59
+ OUT({ l: [4096, 4196, 52011].map((p) => parseNetstatListeners(INPUT.netstat, p)),
60
+ o: [[4096, 1], [4196, 1], [4196, 7002]].map(([p, self]) => winPortOwners(p, { run, user: 'shter', self })) })
61
+ """, {"netstat": NETSTAT, "tasklist": {str(k): v for k, v in TASKLIST.items()}})
62
+ assert js["l"] == [ports.parse_netstat_listeners(NETSTAT, p) for p in (4096, 4196, 52011)]
63
+ assert js["o"] == [ports.win_owners(p, run=fake_run, user="shter", self_pid=s) for p, s in ((4096, 1), (4196, 1), (4196, 7002))]
64
+
65
+
66
+ def test_stopping_on_windows_is_taskkill_of_the_tree_and_never_os_kill(monkeypatch):
67
+ def no_kill(*a):
68
+ raise AssertionError("os.kill on Windows: signal 0 is CTRL_C_EVENT — a Ctrl-C to setup's own window")
69
+
70
+ monkeypatch.setattr(os, "kill", no_kill)
71
+ ran, left = [], [3]
72
+
73
+ def alive(pid: int) -> bool:
74
+ left[0] -= 1
75
+ return left[0] > 0
76
+
77
+ assert uninstall.win_stop_process(4242, run=ran.append, alive=alive, sleep=lambda s: None) is True
78
+ assert ran == [["taskkill", "/PID", "4242", "/T", "/F"]]
79
+ assert uninstall.win_stop_process(4243, run=lambda a: None, alive=lambda p: True, sleep=lambda s: None) is False
80
+ stopped = []
81
+ monkeypatch.setattr(sys, "platform", "win32")
82
+ monkeypatch.setattr(uninstall, "win_stop_process", lambda pid: stopped.append(pid) or True)
83
+ assert uninstall.stop_process(5150) is True and stopped == [5150]
@@ -100,3 +100,31 @@ def test_the_connector_answers_tools_with_the_current_nonce_only(make, tmp_path)
100
100
  await connector.stop()
101
101
 
102
102
  run(go())
103
+
104
+
105
+ WIN_DIRS_JS = """
106
+ import { probeTools } from '@TOOLS@/code-tools-probe.mjs'
107
+ const files = new Set(INPUT.files)
108
+ OUT(probeTools({ env: INPUT.env, platform: 'win32', home: '', isExecutable: (p) => files.has(p), listdir: (d) => INPUT.dirs[d] || [] }))
109
+ """
110
+
111
+
112
+ def test_tesseract_and_qpdf_are_found_where_their_installers_put_them_off_path():
113
+ # the owner's laptop, 2026-10-09: winget installed both, PATH has neither, setup said "Still missing: qpdf, Tesseract"
114
+ env = {"PATH": "C:\\Windows\\System32", "ProgramFiles": "C:\\Program Files", "ProgramFiles(x86)": "C:\\Program Files (x86)",
115
+ "LOCALAPPDATA": "C:\\Users\\u\\AppData\\Local"}
116
+ dirs = {"C:\\Program Files": ["Git", "qpdf 12.4.2", "Tesseract-OCR"], "C:\\Users\\u\\AppData\\Local\\Programs": ["qpdf"]}
117
+ files = ["C:\\Program Files\\Tesseract-OCR\\tesseract.exe", "C:\\Program Files\\qpdf 12.4.2\\bin\\qpdf.exe"]
118
+ got = probe_tools(env=env, platform="win32", home="", is_executable=set(files).__contains__, listdir=lambda d: dirs.get(d, []))
119
+ assert got["tools"]["tesseract"] and got["tools"]["qpdf"]
120
+ none = probe_tools(env=env, platform="win32", home="", is_executable=lambda p: False, listdir=lambda d: dirs.get(d, []))
121
+ assert not none["tools"]["tesseract"] and not none["tools"]["qpdf"]
122
+
123
+
124
+ @requires_node
125
+ def test_the_javascript_probe_looks_in_the_same_program_folders():
126
+ env = {"PATH": "", "ProgramFiles": "C:\\Program Files\\", "ProgramW6432": "C:\\Program Files", "LOCALAPPDATA": "C:\\L"}
127
+ dirs = {"C:\\Program Files": ["QPDF 11", "qpdf 12.4.2"], "C:\\L\\Programs": ["Tesseract-OCR", "qpdf-x"]}
128
+ for files in (["C:\\Program Files\\QPDF 11\\bin\\qpdf.exe"], ["C:\\L\\Programs\\Tesseract-OCR\\tesseract.EXE"], ["C:\\L\\Programs\\qpdf-x\\bin\\qpdf.exe"], []):
129
+ js = run_node(WIN_DIRS_JS, {"env": env, "dirs": dirs, "files": files})
130
+ assert probe_tools(env=env, platform="win32", home="", is_executable=set(files).__contains__, listdir=lambda d: dirs.get(d, [])) == js, files
@@ -77,3 +77,14 @@ def test_setups_poppler_joins_the_agents_path_when_it_is_there(tmp_path):
77
77
  assert add["PATH_PREPEND"] == [r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts", r"C:\Users\t\AppData\Local\Witbitz\poppler\bin"]
78
78
  add2, _, _ = winagent.windows_agent(tmp_path, env=ENV, platform="win32", exists=exists_of(py))
79
79
  assert add2["PATH_PREPEND"] == [r"C:\Users\t\AppData\Local\Witbitz\agent-python\Scripts"]
80
+
81
+
82
+ def test_tesseract_and_qpdf_join_the_agents_path_where_their_installers_put_them(tmp_path):
83
+ # the owner's laptop, 2026-10-09: winget installed both and neither installer touches PATH
84
+ env = {**ENV, "ProgramFiles": r"C:\Program Files"}
85
+ tess, qpdf = r"C:\Program Files\Tesseract-OCR\tesseract.exe", r"C:\Program Files\qpdf 12.4.2\bin\qpdf.exe"
86
+ listdir = {r"C:\Program Files": ["qpdf 12.4.2", "Tesseract-OCR"]}.get
87
+ add, _, _ = winagent.windows_agent(tmp_path, env=env, platform="win32", exists=exists_of(tess, qpdf), listdir=lambda d: listdir(d, []))
88
+ assert add["PATH_PREPEND"] == [r"C:\Program Files\Tesseract-OCR", r"C:\Program Files\qpdf 12.4.2\bin"]
89
+ none, _, _ = winagent.windows_agent(tmp_path, env=env, platform="win32", exists=exists_of(), listdir=lambda d: listdir(d, []))
90
+ assert "PATH_PREPEND" not in none
File without changes
File without changes
File without changes