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.
- {witbitz_code-1.3.6/src/witbitz_code.egg-info → witbitz_code-1.3.7}/PKG-INFO +1 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/pyproject.toml +1 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/cli.py +5 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/ports.py +53 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/tools_probe.py +26 -4
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/uninstall.py +37 -2
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/winagent.py +6 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.7/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/SOURCES.txt +1 -0
- witbitz_code-1.3.7/tests/test_ports_win.py +83 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_tools_probe.py +28 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_winagent.py +11 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/LICENSE +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/README.md +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/setup.cfg +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/wizard.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_ports.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_setup.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.7}/tests/test_windows_files.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.3.
|
|
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"
|
|
@@ -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;
|
|
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))
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs
RENAMED
|
File without changes
|
{witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-notes.js
RENAMED
|
File without changes
|
{witbitz_code-1.3.6 → witbitz_code-1.3.7}/src/witbitz_code/opencode_plugins/witbitz-progress.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|