witbitz-code 1.3.6__tar.gz → 1.3.8__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.8}/PKG-INFO +5 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/README.md +4 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/pyproject.toml +1 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/cli.py +10 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/pair.py +20 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/pairings.py +7 -1
- witbitz_code-1.3.8/src/witbitz_code/ports.py +167 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/tools_probe.py +26 -4
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/toolsetup.py +14 -2
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/uninstall.py +37 -2
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/winagent.py +6 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/wizard.py +39 -3
- {witbitz_code-1.3.6 → witbitz_code-1.3.8/src/witbitz_code.egg-info}/PKG-INFO +5 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/SOURCES.txt +1 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_pair_flows.py +27 -0
- witbitz_code-1.3.8/tests/test_ports_win.py +109 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_setup.py +51 -7
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_tools_probe.py +28 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_toolsetup.py +22 -1
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_winagent.py +11 -0
- witbitz_code-1.3.6/src/witbitz_code/ports.py +0 -87
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/LICENSE +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/setup.cfg +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_asks.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_attachments.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_auto.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_cli.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_codetools.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_confidential.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_folders.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_outputs.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_plugins.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_policy.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_ports.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_registry.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_uninstall.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.3.6 → witbitz_code-1.3.8}/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.
|
|
3
|
+
Version: 1.3.8
|
|
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
|
|
@@ -88,6 +88,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
88
88
|
|
|
89
89
|
`pair`, `rotate` and `unpair` also accept `--dry-run`.
|
|
90
90
|
|
|
91
|
+
**Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
|
|
92
|
+
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
93
|
+
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
94
|
+
|
|
91
95
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
92
96
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
93
97
|
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
@@ -62,6 +62,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
62
62
|
|
|
63
63
|
`pair`, `rotate` and `unpair` also accept `--dry-run`.
|
|
64
64
|
|
|
65
|
+
**Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
|
|
66
|
+
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
67
|
+
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
68
|
+
|
|
65
69
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
66
70
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
67
71
|
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
@@ -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.8"
|
|
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"
|
|
@@ -156,11 +156,20 @@ async def serve(args: list[str]) -> int:
|
|
|
156
156
|
if not everything:
|
|
157
157
|
_err("witbitz-code: this computer is not paired yet — run: witbitz-code setup")
|
|
158
158
|
return 1
|
|
159
|
+
# No --port: the port this computer is paired for (setup moves a pairing off a port WSL's or Windows' copy holds)
|
|
160
|
+
paired_ports = sorted({wizard.port_of(p) for p in everything})
|
|
161
|
+
if "--port" not in args and len(paired_ports) == 1:
|
|
162
|
+
port = paired_ports[0]
|
|
159
163
|
# One OpenCode per port, one connector per OpenCode: two `serve`s (one per account) must never both answer a channel.
|
|
160
164
|
pairings = pairings_for_port(everything, port)
|
|
161
165
|
if not pairings:
|
|
162
166
|
_err(f"witbitz-code: no pairing uses OpenCode on port {port} — pair with: witbitz-code pair --port {port}")
|
|
163
167
|
return 1
|
|
168
|
+
# Windows: a tool installed since this window opened (setup's Tools step, winget) is on the registry's PATH, not yet on
|
|
169
|
+
# this process's — read it, so the agent's OpenCode finds it (= witbitz-code.mjs serve)
|
|
170
|
+
reg = toolsetup.registry_path()
|
|
171
|
+
if reg:
|
|
172
|
+
os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
|
|
164
173
|
# Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
|
|
165
174
|
# written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
|
|
166
175
|
# running picks them up at its next restart.
|
|
@@ -306,7 +315,7 @@ async def setup(args: list[str]) -> int:
|
|
|
306
315
|
d = wizard.Deps(
|
|
307
316
|
say=_err, ask=lambda q: keys.read_line(q), secret=lambda q: keys.read_line(q, hidden=True),
|
|
308
317
|
port=port, port_explicit="--port" in args,
|
|
309
|
-
pair=lambda p: pair_cmd.main(["--port", str(p)]),
|
|
318
|
+
pair=lambda p: pair_cmd.main(["--port", str(p)]), rename=lambda p, name: pair_cmd.rename(p, name),
|
|
310
319
|
keys_state=keys_state,
|
|
311
320
|
run_tools=lambda plan: toolsetup.run_plan(plan, say=_err), refresh_path=refresh_path,
|
|
312
321
|
is_listening=is_listening, serve_here=lambda p: serve(["--port", str(p)]),
|
|
@@ -124,6 +124,26 @@ async def _link_by_qr(link_fn: LinkFn | None, out: Callable, err: Callable) -> d
|
|
|
124
124
|
return account
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
async def rename(pairing: dict, name: str, *, call: net.ApiCall | None = None, out: Callable[[str], Any] = _out,
|
|
128
|
+
err: Callable[[str], Any] = _err) -> bool:
|
|
129
|
+
"""A pairing's label: in the account's computers (devices show it at their next sync), then in the pairings file. No scan."""
|
|
130
|
+
client = None
|
|
131
|
+
if call is None:
|
|
132
|
+
client = net.new_client()
|
|
133
|
+
call = net.make_api_call(client)
|
|
134
|
+
try:
|
|
135
|
+
if not await _publish_entry(call, {**pairing, "name": name}, out, err):
|
|
136
|
+
return False
|
|
137
|
+
finally:
|
|
138
|
+
if client is not None:
|
|
139
|
+
await client.aclose()
|
|
140
|
+
doc = pp.read_pairings()
|
|
141
|
+
doc["pairings"] = [({**x, "name": name} if isinstance(x, dict) and x.get("computerId") == pairing.get("computerId") else x)
|
|
142
|
+
for x in doc.get("pairings") or []]
|
|
143
|
+
pp.write_pairings(doc)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
127
147
|
async def main(argv: list[str], *, call: net.ApiCall | None = None, link_fn: LinkFn | None = None,
|
|
128
148
|
out: Callable[[str], Any] = _out, err: Callable[[str], Any] = _err, keys: Any = None) -> int:
|
|
129
149
|
"""pair / --status / --unpair / --rotate. Returns the exit code. `keys`: the coding-key calls (codekeys.mint /
|
|
@@ -91,7 +91,13 @@ def new_computer_id() -> str:
|
|
|
91
91
|
|
|
92
92
|
|
|
93
93
|
def hostname() -> str:
|
|
94
|
-
|
|
94
|
+
"""The label a new pairing gets: this computer's name — on WSL "<name> (WSL)": WSL calls itself by the Windows computer's
|
|
95
|
+
name, and the Windows side and the Linux side are two computers in the account (the owner, 2026-10-09: "one running in
|
|
96
|
+
WSL and one in windows … noted as separate devices")."""
|
|
97
|
+
from .ports import on_wsl # noqa: PLC0415
|
|
98
|
+
|
|
99
|
+
h = socket.gethostname()
|
|
100
|
+
return f"{h} (WSL)" if on_wsl() else h
|
|
95
101
|
|
|
96
102
|
|
|
97
103
|
# ── the pairings doc (pure) ───────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,167 @@
|
|
|
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; 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.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def port_owners(port: int) -> list[dict]:
|
|
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)
|
|
25
|
+
if Path("/proc/net/tcp").exists():
|
|
26
|
+
return _proc_owners(port)
|
|
27
|
+
if shutil.which("lsof"):
|
|
28
|
+
return _lsof_owners(port)
|
|
29
|
+
return []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _proc_owners(port: int) -> list[dict]:
|
|
33
|
+
inodes: set[str] = set()
|
|
34
|
+
for f in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
35
|
+
try:
|
|
36
|
+
lines = Path(f).read_text().splitlines()[1:]
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
for line in lines:
|
|
40
|
+
c = line.split()
|
|
41
|
+
if len(c) >= 10 and c[3] == "0A" and int(c[1].rsplit(":", 1)[-1], 16) == port: # 0A = LISTEN
|
|
42
|
+
inodes.add(c[9])
|
|
43
|
+
if not inodes:
|
|
44
|
+
return []
|
|
45
|
+
hits = []
|
|
46
|
+
for p in os.listdir("/proc"):
|
|
47
|
+
if not p.isdigit() or int(p) == os.getpid():
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
fds = os.listdir(f"/proc/{p}/fd")
|
|
51
|
+
except OSError:
|
|
52
|
+
continue # not this user's
|
|
53
|
+
owns = False
|
|
54
|
+
for fd in fds:
|
|
55
|
+
try:
|
|
56
|
+
link = os.readlink(f"/proc/{p}/fd/{fd}")
|
|
57
|
+
except OSError:
|
|
58
|
+
continue
|
|
59
|
+
if link.startswith("socket:[") and link[8:-1] in inodes:
|
|
60
|
+
owns = True
|
|
61
|
+
break
|
|
62
|
+
if not owns:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
cmd = " ".join(x for x in Path(f"/proc/{p}/cmdline").read_bytes().decode("utf-8", "replace").split("\0") if x)
|
|
66
|
+
except OSError:
|
|
67
|
+
cmd = ""
|
|
68
|
+
hits.append({"pid": int(p), "cmd": cmd})
|
|
69
|
+
return hits
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _lsof_owners(port: int) -> list[dict]:
|
|
73
|
+
try:
|
|
74
|
+
out = subprocess.run(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], capture_output=True, text=True, timeout=10).stdout
|
|
75
|
+
except (OSError, subprocess.SubprocessError):
|
|
76
|
+
return []
|
|
77
|
+
hits = []
|
|
78
|
+
for pid in sorted({int(x) for x in out.split() if x.isdigit()}):
|
|
79
|
+
if pid == os.getpid():
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
cmd = subprocess.run(["ps", "-o", "command=", "-p", str(pid)], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
83
|
+
except (OSError, subprocess.SubprocessError):
|
|
84
|
+
cmd = ""
|
|
85
|
+
hits.append({"pid": pid, "cmd": cmd})
|
|
86
|
+
return hits
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def on_wsl() -> bool:
|
|
90
|
+
try:
|
|
91
|
+
return "microsoft" in Path("/proc/version").read_text().lower()
|
|
92
|
+
except OSError:
|
|
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
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_status(argv: list[str], env: dict | None = None) -> tuple[int, str]:
|
|
143
|
+
try:
|
|
144
|
+
r = subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=15, env=env)
|
|
145
|
+
return r.returncode, r.stdout or ""
|
|
146
|
+
except (OSError, subprocess.SubprocessError):
|
|
147
|
+
return -1, ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def wsl_running(run: Callable[[list[str]], tuple[int, str]] | None = None) -> list[str]:
|
|
151
|
+
"""Windows: the WSL distros running now. WSL_UTF8=1, or wsl.exe answers in UTF-16; a distro name has no spaces, so a
|
|
152
|
+
message line ("There are no running distributions.") is never taken for one."""
|
|
153
|
+
run = run or (lambda argv: _run_status(argv, {**os.environ, "WSL_UTF8": "1"}))
|
|
154
|
+
code, out = run(["wsl", "-l", "--running", "-q"])
|
|
155
|
+
if code != 0:
|
|
156
|
+
return []
|
|
157
|
+
return [n for n in (ln.strip() for ln in out.replace("\x00", "").splitlines()) if re.fullmatch(r"[A-Za-z0-9._-]+", n)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def held_in_wsl(port: int, *, platform: str | None = None, run: Callable[[list[str]], str] = _run_text,
|
|
161
|
+
wsl: Callable[[], list[str]] = wsl_running) -> list[str]:
|
|
162
|
+
"""Windows: the running WSL distros, when `port` answers but no Windows program listens on it — Linux and Windows share
|
|
163
|
+
localhost ports (WSL's mirrored networking) and Windows cannot name the Linux program (the owner's laptop, 2026-10-09:
|
|
164
|
+
netstat showed nothing on 4096, UbuntuD was running an earlier witbitz-code serve). [] off Windows."""
|
|
165
|
+
if (sys.platform if platform is None else platform) != "win32" or parse_netstat_listeners(run(["netstat", "-ano"]), port):
|
|
166
|
+
return []
|
|
167
|
+
return wsl()
|
|
@@ -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:
|
|
@@ -119,6 +119,15 @@ def install_release(step: dict, *, client: httpx.Client | None = None, dest: str
|
|
|
119
119
|
return str(to)
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
# winget's "no newer version" (APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE, 0x8A15002B) and "already installed"
|
|
123
|
+
# (0x8A150061): the tool is HERE (the owner's laptop, 2026-10-09: Pandoc and ImageMagick "ended with 2316632107"). = runPlan
|
|
124
|
+
WINGET_ALREADY = (0x8A15002B, 0x8A150061)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def already_installed(argv: list[str], code: int) -> bool:
|
|
128
|
+
return argv[:1] == ["winget"] and code != -1 and (code & 0xFFFFFFFF) in WINGET_ALREADY
|
|
129
|
+
|
|
130
|
+
|
|
122
131
|
def run_plan(steps: list[dict], *, say: Callable[[str], Any] = print, run: Callable[[list[str]], int] = run_inherit,
|
|
123
132
|
release: Callable[[dict], str] = install_release) -> dict:
|
|
124
133
|
"""Carry out the plan → {done, failed, auto, none} (tool ids). Says each step as it goes. = code-tools-install.mjs runPlan"""
|
|
@@ -127,8 +136,11 @@ def run_plan(steps: list[dict], *, say: Callable[[str], Any] = print, run: Calla
|
|
|
127
136
|
if s["kind"] == "cmd":
|
|
128
137
|
say(f" $ {s['argv'][2] if s['argv'][0] == 'sh' else ' '.join(s['argv'])}")
|
|
129
138
|
code = run(s["argv"])
|
|
130
|
-
|
|
131
|
-
if code
|
|
139
|
+
here = code != 0 and already_installed(s["argv"], code)
|
|
140
|
+
(out["done"] if code == 0 or here else out["failed"]).extend(s["ids"])
|
|
141
|
+
if here:
|
|
142
|
+
say(f" ✓ {', '.join(label(i) for i in s['ids'])}: already installed")
|
|
143
|
+
elif code != 0:
|
|
132
144
|
say(f" ✖ {', '.join(label(i) for i in s['ids'])}: that {'could not start' if code == -1 else f'ended with {code}'}")
|
|
133
145
|
elif s["kind"] == "release":
|
|
134
146
|
say(f" Downloading {label(s['id'])} ({s['mb']} MB) from its publisher, checked against its SHA-256 …")
|
|
@@ -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
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import os
|
|
16
16
|
import shutil
|
|
17
|
+
import socket
|
|
17
18
|
import subprocess
|
|
18
19
|
import sys
|
|
19
20
|
from collections.abc import Awaitable, Callable
|
|
@@ -75,6 +76,9 @@ class Deps:
|
|
|
75
76
|
forget_keys: Callable[[dict], Any] = lambda p: codekeys.forget(p.get("computerId") or "")
|
|
76
77
|
is_listening: Callable[[int], Awaitable[bool]] = None # type: ignore[assignment]
|
|
77
78
|
port_owners: Callable[[int], list] = ports.port_owners # [{pid, cmd}] — this user's processes on a port (ports.py)
|
|
79
|
+
held_in_wsl: Callable[[int], list] = ports.held_in_wsl # Windows: running WSL distros when no Windows program holds the port
|
|
80
|
+
bare_host: Callable[[], str] = socket.gethostname # the computer's own name (WSL reports Windows')
|
|
81
|
+
rename: Callable[[dict, str], Awaitable[bool]] | None = None # pair.rename: a pairing's label, here and in the account
|
|
78
82
|
stop_process: Callable[[int], bool] = lambda pid: __import__("witbitz_code.uninstall", fromlist=["stop_process"]).stop_process(pid)
|
|
79
83
|
wsl: bool = field(default_factory=ports.on_wsl)
|
|
80
84
|
# setup's Tools step (toolsetup.py): what is here, carrying out the plan, and PATH after installs (Windows: the registry)
|
|
@@ -158,6 +162,11 @@ async def run_setup(d: Deps) -> dict:
|
|
|
158
162
|
d.say(f" ✖ This computer is paired, but for OpenCode on another port. Run setup again with that port: witbitz-code setup --port {d.port_of(everything[0])}"
|
|
159
163
|
if everything else " ✖ Pairing did not finish. Run setup again to retry.")
|
|
160
164
|
return {**result, "stopped": "pair"}
|
|
165
|
+
if d.wsl and d.rename: # WSL calls itself by the Windows computer's name: two computers in the account, one name (1.3.7 and before)
|
|
166
|
+
for p in [x for x in mine if x.get("name") == d.bare_host()]:
|
|
167
|
+
if await d.rename(p, f"{p['name']} (WSL)"):
|
|
168
|
+
d.say(f' ✓ Renamed this computer "{p["name"]} (WSL)" — Windows on the same machine keeps "{p["name"]}"')
|
|
169
|
+
mine = on(d.all_pairings(), port)
|
|
161
170
|
d.say(f" ✓ Paired: {named(mine)}{f' (OpenCode on port {port})' if port != 4096 else ''}")
|
|
162
171
|
result["paired"] = True
|
|
163
172
|
|
|
@@ -201,9 +210,24 @@ async def run_setup(d: Deps) -> dict:
|
|
|
201
210
|
if all(o["pid"] != x["pid"] for x in owners):
|
|
202
211
|
owners.append(o)
|
|
203
212
|
if not owners:
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
213
|
+
# Not this person's program here — on Windows most likely the witbitz-code in WSL (Linux and Windows share these
|
|
214
|
+
# ports; the owner, 2026-10-09: "I want one running in WSL and one in windows"). Both can run: this one moves.
|
|
215
|
+
free = await _free_port(d, port + 1)
|
|
216
|
+
distros = d.held_in_wsl(busy[0])
|
|
217
|
+
if distros:
|
|
218
|
+
d.say(f" Port {busy[0]} is used inside WSL (Linux on this computer — running: {', '.join(distros)}), most likely by the")
|
|
219
|
+
d.say(" witbitz-code there: Windows and WSL share these ports. Both can run — this one just needs its own.")
|
|
220
|
+
else:
|
|
221
|
+
d.say(f" Port {busy[0]} is taken by a program that is not yours{' — on WSL, Windows and the other Linux distros on this computer share its ports' if d.wsl else ''}.")
|
|
222
|
+
if not _yes(d.ask(f" Use port {free} (and {free + 100} for the model proxy) here instead? [Y/n] ")):
|
|
223
|
+
d.say(f" Stopped. To use another port later: witbitz-code setup --port {free}"
|
|
224
|
+
+ (" — or stop WSL's copy: wsl --shutdown" if distros else ""))
|
|
225
|
+
return {**result, "stopped": "busy"}
|
|
226
|
+
for p in on(d.all_pairings(), port):
|
|
227
|
+
d.move_pairing(p, free)
|
|
228
|
+
d.say(f" ✓ This computer now uses port {free} — `witbitz-code serve` remembers it")
|
|
229
|
+
port = free
|
|
230
|
+
continue
|
|
207
231
|
listed = "; ".join(f"{(o.get('cmd') or 'a program')[:70]} (process {o['pid']})" for o in owners)
|
|
208
232
|
d.say(f" Already running here: {listed}.")
|
|
209
233
|
d.say(" That is an earlier witbitz-code or an OpenCode started some other way — it would keep answering your phone with")
|
|
@@ -240,8 +264,20 @@ def _describe(step: dict) -> str:
|
|
|
240
264
|
return f" {toolsetup.label(step['id'])}: nothing here installs it" + (f" ({step['note']})" if step.get("note") else "")
|
|
241
265
|
|
|
242
266
|
|
|
267
|
+
async def _free_port(d: Deps, start: int) -> int:
|
|
268
|
+
"""The first port from `start` that is free together with its model proxy's (port + 100)."""
|
|
269
|
+
for p in range(max(start, 1024), 65436):
|
|
270
|
+
if not await d.is_listening(p) and not await d.is_listening(p + 100):
|
|
271
|
+
return p
|
|
272
|
+
return start
|
|
273
|
+
|
|
274
|
+
|
|
243
275
|
async def _tools_step(d: Deps) -> str:
|
|
244
276
|
"""→ 'here' | 'installed' | 'partly' | 'skipped' | 'unavailable' | 'failed' (an install that breaks never ends setup)."""
|
|
277
|
+
try: # Windows: what an earlier setup installed is on the registry's PATH, not yet on this window's — read it FIRST, or
|
|
278
|
+
d.refresh_path() # winget is asked again for tools that are here (the owner's laptop, 2026-10-09: Pandoc, ImageMagick)
|
|
279
|
+
except Exception: # noqa: BLE001
|
|
280
|
+
pass
|
|
245
281
|
try:
|
|
246
282
|
report = d.probe_tools()
|
|
247
283
|
except Exception: # noqa: BLE001 — a probe that fails never stops setup
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: witbitz-code
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.8
|
|
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
|
|
@@ -88,6 +88,10 @@ The same thing one step at a time: install OpenCode (`curl -fsSL https://opencod
|
|
|
88
88
|
|
|
89
89
|
`pair`, `rotate` and `unpair` also accept `--dry-run`.
|
|
90
90
|
|
|
91
|
+
**Windows and WSL on one computer** are two computers to Spaces, and both can run: WSL shares Windows' ports, so the second
|
|
92
|
+
one `setup` meets gets its own (it asks — 4097, and 4197 for its model proxy) and `serve` remembers it. WSL's is named
|
|
93
|
+
"<computer> (WSL)"; one paired before 1.3.8 is renamed by its next `setup`.
|
|
94
|
+
|
|
91
95
|
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
92
96
|
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
93
97
|
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
@@ -274,3 +274,30 @@ def test_status_output_is_json_free_of_secrets_even_for_js_written_files():
|
|
|
274
274
|
run(pair.main(["--status"], **cap.kw()))
|
|
275
275
|
assert SECRET not in cap.text() and "MK" not in cap.text()
|
|
276
276
|
assert json.loads(pp.pairings_path().read_text())["pairings"][0]["secret"] == SECRET
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_rename_relabels_the_computer_in_the_account_and_here_with_no_scan(monkeypatch):
|
|
280
|
+
# WSL's pairing and Windows' were two "AlonASUS" in Code (the owner, 2026-10-09); setup renames WSL's "… (WSL)"
|
|
281
|
+
api = MockApi(docs={"computers": with_computer(None, "cmp_wsl_1", {"name": "AlonASUS", "relay": RELAY, "secret": SECRET}, 500)})
|
|
282
|
+
entry = {"account": "a@x.com", "idx": IDX, "computerId": "cmp_wsl_1", "secret": SECRET, "name": "AlonASUS", "relay": RELAY,
|
|
283
|
+
"opencodeUrl": "http://127.0.0.1:4096"}
|
|
284
|
+
pp.write_pairings({"v": 1, "pairings": [entry]})
|
|
285
|
+
assert run(pair.rename(entry, "AlonASUS (WSL)", call=api.call, out=lambda m: None, err=lambda m: None)) is True
|
|
286
|
+
reg = norm_registry(api.plain("computers"))
|
|
287
|
+
assert reg["computers"]["cmp_wsl_1"]["name"] == "AlonASUS (WSL)" and reg["computers"]["cmp_wsl_1"]["secret"] == SECRET
|
|
288
|
+
assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["AlonASUS (WSL)"]
|
|
289
|
+
refused = MockApi(read_status=500)
|
|
290
|
+
assert run(pair.rename({**entry, "name": "AlonASUS (WSL)"}, "x", call=refused.call, out=lambda m: None, err=lambda m: None)) is False
|
|
291
|
+
assert [p["name"] for p in pp.read_pairings()["pairings"]] == ["AlonASUS (WSL)"], "nothing renamed here when the account refused"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_a_new_pairing_on_wsl_is_named_for_wsl(monkeypatch):
|
|
295
|
+
import socket
|
|
296
|
+
|
|
297
|
+
from witbitz_code import ports
|
|
298
|
+
|
|
299
|
+
monkeypatch.setattr(socket, "gethostname", lambda: "AlonASUS")
|
|
300
|
+
monkeypatch.setattr(ports, "on_wsl", lambda: True)
|
|
301
|
+
assert pp.hostname() == "AlonASUS (WSL)"
|
|
302
|
+
monkeypatch.setattr(ports, "on_wsl", lambda: False)
|
|
303
|
+
assert pp.hostname() == "AlonASUS"
|