witbitz-code 1.2.0__py3-none-any.whl

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/cli.py ADDED
@@ -0,0 +1,223 @@
1
+ """witbitz-code — connect the Spaces Code section to OpenCode on this computer (docs/opencode-relay.md). The same commands
2
+ as the single-file Node download (tools/witbitz-code.mjs):
3
+
4
+ witbitz-code pair [--name desk] scan the QR with the Spaces app → this computer joins that account
5
+ witbitz-code serve [--port 4096] start OpenCode (if it is not running) and the connector
6
+ witbitz-code status what this computer is paired with
7
+ witbitz-code rotate new secrets (no scan) — restart serve afterwards
8
+ witbitz-code unpair [--account a@b] remove this computer from an account
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import os
15
+ import shutil
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ from typing import Any
20
+
21
+ import httpx
22
+
23
+ from . import __version__
24
+ from . import pair as pair_cmd
25
+ from . import pairings as pp
26
+ from . import codetools, plugins, policy
27
+ from ._js import to_number, truthy
28
+ from .connector import pairings_for_port, start_connector
29
+
30
+ HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from the Spaces Code section, end-to-end encrypted.
31
+
32
+ pair [--name <name>] show a QR code; scan it in Spaces (Settings → Back up & recovery → Add a device)
33
+ serve [--port <n>] [--no-opencode]
34
+ start OpenCode on 127.0.0.1 (unless it is already running) and the connector
35
+ status list this computer's pairings
36
+ rotate [--account <email>] replace the pairing secret(s) without a scan, then restart serve
37
+ unpair [--account <email>] remove this computer from an account
38
+
39
+ Nothing listens on the network: OpenCode stays on 127.0.0.1 and the connector dials out to wss://code-relay.witbitz.chat.
40
+ """
41
+
42
+
43
+ def _err(msg: str) -> None:
44
+ print(msg, file=sys.stderr, flush=True)
45
+
46
+
47
+ def _flag(args: list[str], name: str, dflt: str = "") -> str:
48
+ if name not in args:
49
+ return dflt
50
+ i = args.index(name)
51
+ return args[i + 1] if i + 1 < len(args) else ""
52
+
53
+
54
+ async def is_listening(port: int) -> bool:
55
+ try:
56
+ async with httpx.AsyncClient(trust_env=False, timeout=1.5) as c:
57
+ await c.get(f"http://127.0.0.1:{port}/")
58
+ return True
59
+ except httpx.HTTPError:
60
+ return False
61
+
62
+
63
+ class _Stop:
64
+ """Ctrl-C / SIGTERM → a flag, installed BEFORE OpenCode is started so a signal during startup cannot orphan it. The
65
+ first signal asks politely; the handlers are then removed, so a second Ctrl-C interrupts even a stuck shutdown."""
66
+
67
+ def __init__(self) -> None:
68
+ self.event = asyncio.Event()
69
+ self._loop = asyncio.get_running_loop()
70
+ self._sigs: list[int] = []
71
+ for sig in (signal.SIGINT, signal.SIGTERM):
72
+ try:
73
+ self._loop.add_signal_handler(sig, self._fire)
74
+ self._sigs.append(sig)
75
+ except (NotImplementedError, RuntimeError):
76
+ pass # Windows: Ctrl-C arrives as KeyboardInterrupt, and the finally blocks still stop OpenCode
77
+
78
+ def _fire(self) -> None:
79
+ self.event.set()
80
+ self.remove()
81
+
82
+ def remove(self) -> None:
83
+ for sig in self._sigs:
84
+ try:
85
+ self._loop.remove_signal_handler(sig)
86
+ except (NotImplementedError, RuntimeError, ValueError):
87
+ pass
88
+ self._sigs = []
89
+
90
+
91
+ async def _wait_for_stop(stop: _Stop, child: asyncio.subprocess.Process | None) -> int:
92
+ """Until Ctrl-C / SIGTERM (→ 0) or OpenCode exits (→ its code)."""
93
+ waiters = [asyncio.ensure_future(stop.event.wait())]
94
+ if child is not None:
95
+ waiters.append(asyncio.ensure_future(child.wait()))
96
+ try:
97
+ await asyncio.wait(waiters, return_when=asyncio.FIRST_COMPLETED)
98
+ finally:
99
+ for w in waiters:
100
+ w.cancel()
101
+ if not stop.event.is_set() and child is not None and child.returncode is not None:
102
+ _err(f"witbitz-code: OpenCode exited ({child.returncode}) — stopping")
103
+ return child.returncode or 0
104
+ return 0
105
+
106
+
107
+ async def _start_opencode(exe: str, port: int, media: dict | None = None, tools: tuple[dict, dict] | None = None) -> asyncio.subprocess.Process:
108
+ password = pp.read_env_password()
109
+ # Subagents ask like their parent, and a refused call does not end the turn (policy.py) — merged by OpenCode over the
110
+ # user's own config, which is never written.
111
+ tools_env, tools_config = tools or ({}, {}) # codetools.py: the lsp tool, ast-grep on PATH, the hint naming it
112
+ env = {**os.environ, **tools_env, **({"OPENCODE_SERVER_PASSWORD": password} if password else {}),
113
+ "OPENCODE_CONFIG_CONTENT": policy.config_content(os.environ.get("OPENCODE_CONFIG_CONTENT"), policy.merge_config(media or {}, tools_config))}
114
+ # The resolved path, not "opencode": on Windows the npm install is opencode.cmd, which a bare name does not find.
115
+ # Its own process group there, so stopping it can take the whole tree (cmd.exe → node) with it.
116
+ extra = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} if sys.platform == "win32" else {}
117
+ return await asyncio.create_subprocess_exec(exe, "serve", "--port", str(port), "--hostname", "127.0.0.1", env=env, **extra)
118
+
119
+
120
+ async def _stop_opencode(child: asyncio.subprocess.Process) -> None:
121
+ if child.returncode is not None:
122
+ return
123
+ if sys.platform == "win32":
124
+ subprocess.run(["taskkill", "/T", "/F", "/PID", str(child.pid)], capture_output=True, check=False)
125
+ else:
126
+ child.terminate()
127
+ try:
128
+ await asyncio.wait_for(child.wait(), 5)
129
+ except asyncio.TimeoutError:
130
+ child.kill()
131
+ await child.wait()
132
+
133
+
134
+ async def serve(args: list[str]) -> int:
135
+ port_n = to_number(_flag(args, "--port", "4096"))
136
+ port = int(port_n) if truthy(port_n) and port_n == port_n and float(port_n).is_integer() else 4096
137
+ everything = pp.load_pairings(log=lambda _m: None)
138
+ if not everything:
139
+ _err("witbitz-code: this computer is not paired yet — run: witbitz-code pair")
140
+ return 1
141
+ # One OpenCode per port, one connector per OpenCode: two `serve`s (one per account) must never both answer a channel.
142
+ pairings = pairings_for_port(everything, port)
143
+ if not pairings:
144
+ _err(f"witbitz-code: no pairing uses OpenCode on port {port} — pair with: witbitz-code pair --port {port}")
145
+ return 1
146
+ # Our OpenCode plugins — project notes, and the agent's progress updates the Code section shows between its work rows —
147
+ # written into OpenCode's config folder on every start (plugins.py). OpenCode reads them when it starts, so one already
148
+ # running picks them up at its next restart.
149
+ try:
150
+ if plugins.install():
151
+ _err(f"witbitz-code: installed OpenCode plugins (project notes, progress updates) in {plugins.opencode_config_dir()}")
152
+ except OSError as e:
153
+ _err(f"witbitz-code: could not install the OpenCode plugins in {plugins.opencode_config_dir()} ({e}) — the agent works without progress updates or notes")
154
+ # The media tools (plugins.py media_config) — one MCP server of image/PDF/video/audio tools, run with this computer's node.
155
+ media, note = plugins.media_config()
156
+ if note:
157
+ _err(note)
158
+ # Structure (codetools.py): OpenCode's lsp tool, ast-grep fetched once (pinned), and the hint that names it.
159
+ tools_env, tools_config, note = await asyncio.to_thread(codetools.code_tools, plugins.opencode_config_dir())
160
+ if note:
161
+ _err(note)
162
+ stop = _Stop()
163
+ child = None
164
+ try:
165
+ if not await is_listening(port) and "--no-opencode" not in args:
166
+ exe = shutil.which("opencode")
167
+ if not exe:
168
+ _err("witbitz-code: OpenCode is not installed (or not on PATH). Install it, then run serve again:")
169
+ _err(" npm install -g opencode-ai or curl -fsSL https://opencode.ai/install | bash")
170
+ return 1
171
+ _err(f"witbitz-code: starting OpenCode on 127.0.0.1:{port}")
172
+ child = await _start_opencode(exe, port, media, (tools_env, tools_config))
173
+ for _ in range(40):
174
+ if stop.event.is_set() or child.returncode is not None or await is_listening(port):
175
+ break
176
+ await asyncio.sleep(0.25)
177
+ if stop.event.is_set():
178
+ return 0
179
+ connector = await start_connector(pairings)
180
+ served = ", ".join(f'"{p.get("name")}" → {p.get("account") or "account"}' for p in pairings)
181
+ _err(f"witbitz-code: serving {served} through the sealed relay (Ctrl-C to stop)")
182
+ try:
183
+ return await _wait_for_stop(stop, child)
184
+ finally:
185
+ await connector.aclose()
186
+ finally:
187
+ if child is not None:
188
+ await _stop_opencode(child)
189
+ stop.remove()
190
+
191
+
192
+ async def _main(argv: list[str]) -> int:
193
+ cmd, rest = (argv[0], argv[1:]) if argv else ("", [])
194
+ if cmd == "pair":
195
+ return await pair_cmd.main(rest)
196
+ if cmd == "serve":
197
+ return await serve(rest)
198
+ if cmd == "status":
199
+ return await pair_cmd.main(["--status"])
200
+ if cmd == "rotate":
201
+ return await pair_cmd.main(["--rotate", *rest])
202
+ if cmd == "unpair":
203
+ return await pair_cmd.main(["--unpair", *rest])
204
+ if cmd in ("version", "--version", "-v"):
205
+ print(__version__)
206
+ return 0
207
+ print(HELP)
208
+ return 2 if cmd and cmd not in ("help", "--help", "-h") else 0
209
+
210
+
211
+ def main(argv: list[str] | None = None) -> Any:
212
+ try:
213
+ code = asyncio.run(_main(sys.argv[1:] if argv is None else argv))
214
+ except KeyboardInterrupt:
215
+ code = 0
216
+ except Exception as e: # network failures in pair/rotate/unpair end here, as the JS FATAL does
217
+ _err(f"witbitz-code: FATAL — {e}")
218
+ code = 1
219
+ sys.exit(code)
220
+
221
+
222
+ if __name__ == "__main__":
223
+ main()
@@ -0,0 +1,128 @@
1
+ """What `serve` adds to the OpenCode it starts so the agent can work on code structurally — the twin of tools/code-tools.mjs.
2
+
3
+ OpenCode's own `lsp` tool (off by default; OPENCODE_EXPERIMENTAL_LSP_TOOL turns it on), a pinned ast-grep fetched once from
4
+ npm's per-platform @ast-grep/cli packages and put first on the agent's PATH, and a short instructions file naming it —
5
+ written only when it is there. tests/test_codetools.py holds the pins and the hint equal to the JS ones.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import io
12
+ import os
13
+ import platform as _platform
14
+ import shutil
15
+ import sys
16
+ import tarfile
17
+ import urllib.request
18
+ import uuid
19
+ from pathlib import Path
20
+
21
+ LSP_TOOL_ENV = {"OPENCODE_EXPERIMENTAL_LSP_TOOL": "true"}
22
+ AST_GREP_VERSION = "0.45.3"
23
+ AST_GREP_ASSETS = {
24
+ "linux-x64": ("linux-x64-gnu", "53828b0acd21dde9f866be2c8513135bf90db9f0c2d931475b8029c534be4ce9"),
25
+ "linux-arm64": ("linux-arm64-gnu", "cb2243c783debf759ac3c75390d64c3811b7d6b7e1b742aea0ce61d7c1b136ac"),
26
+ "darwin-arm64": ("darwin-arm64", "3975513c7cce01bbdc68543c1618aa8b04a75b54fd0142d08805e906dca7cdc7"),
27
+ "darwin-x64": ("darwin-x64", "75a69dc05c97174749cbb4ff8a79b8b30f4db6b52a5728784a3af3f8364b0559"),
28
+ "win32-x64": ("win32-x64-msvc", "e11a5f9d6f831ae79d04d6a7430a43de53f903a1333a2d4e8a9fdd3d9df52c92"),
29
+ "win32-arm64": ("win32-arm64-msvc", "f3ae45e870ae66b1e0cde72bc55f6e349159a7980b5711f0ebad6b9d839cd27c"),
30
+ }
31
+ INSTRUCTIONS_NAME = "witbitz-code-tools.md"
32
+ INSTRUCTIONS = """ast-grep is installed for SYNTAX-AWARE search and rewrite — prefer it to regex or sed for code structure (calls, imports, functions), because it ignores comments and strings:
33
+ - find: `ast-grep run -p 'fetchUser($ID)' -l ts` ($NAME matches one node, $$$ARGS any number)
34
+ - rewrite every match: `ast-grep run -p 'fetchUser($ID)' -r 'loadUser($ID)' -l ts -U`, then review with `git diff`
35
+ Run it as `ast-grep` (on Linux `sg` is a different program).
36
+
37
+ Tools you may expect from other setups, and what stands in for them here — do not go looking or report them missing:
38
+ - list → glob (file names by pattern) or read on a directory.
39
+ - todoread → todowrite returns the whole list on every write.
40
+ - multiedit / patch → edit, one file at a time, or apply_patch where your model is offered it.
41
+ - websearch → web_search (witbitz-media): confidential, answered by a search agent inside a hardware enclave; then webfetch a source for its full text.
42
+ - browser → browser_open / browser_navigate / browser_snapshot / browser_click / browser_type / browser_key / browser_scroll / browser_back / browser_screenshot, when this computer offers them; act from a snapshot's numbered refs, not from a screenshot.
43
+ - lsp is here when the config enables a language server for the file type (Python: pyright); otherwise run the project's own typecheck or lint.
44
+ """
45
+
46
+
47
+ def _platform_key() -> str:
48
+ osname = {"linux": "linux", "darwin": "darwin", "win32": "win32"}.get(sys.platform, sys.platform)
49
+ machine = _platform.machine().lower()
50
+ arch = {"x86_64": "x64", "amd64": "x64", "aarch64": "arm64", "arm64": "arm64"}.get(machine, machine)
51
+ return f"{osname}-{arch}"
52
+
53
+
54
+ def tools_dir(env: dict | None = None) -> Path:
55
+ env = os.environ if env is None else env
56
+ return Path(env.get("WITBITZ_TOOLS_DIR") or (Path.home() / ".cache" / "witbitz" / "tools"))
57
+
58
+
59
+ def _download(url: str) -> bytes:
60
+ with urllib.request.urlopen(url, timeout=120) as r: # registry.npmjs.org; the bytes are checked against the pin below
61
+ return r.read()
62
+
63
+
64
+ def ensure_ast_grep(env: dict | None = None, root: Path | None = None, key: str | None = None, download=_download, assets=AST_GREP_ASSETS) -> tuple[Path | None, bool, str]:
65
+ """→ (the directory holding ast-grep or None, fetched now?, why not). Never raises."""
66
+ env = os.environ if env is None else env
67
+ root = Path(root) if root else tools_dir(env)
68
+ key = key or _platform_key()
69
+ exe = "ast-grep.exe" if key.startswith("win32") else "ast-grep"
70
+ home = root / f"ast-grep-{AST_GREP_VERSION}"
71
+ if (home / exe).exists():
72
+ return home, False, ""
73
+ if env.get("WITBITZ_TOOLS_FETCH") == "0":
74
+ return None, False, "fetching is off (WITBITZ_TOOLS_FETCH=0)"
75
+ if key not in assets:
76
+ return None, False, f"no ast-grep build for {key}"
77
+ suffix, pin = assets[key]
78
+ tmp = root / f"{home.name}.{uuid.uuid4().hex[:8]}"
79
+ try:
80
+ data = download(f"https://registry.npmjs.org/@ast-grep/cli-{suffix}/-/cli-{suffix}-{AST_GREP_VERSION}.tgz")
81
+ got = hashlib.sha256(data).hexdigest()
82
+ if got != pin:
83
+ return None, False, f"refused: its SHA-256 is {got}, not the pinned {pin}"
84
+ tmp.mkdir(parents=True)
85
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as t:
86
+ for m in t.getmembers(): # only package/<name> files — never a path that climbs out
87
+ if m.isfile() and m.name.startswith("package/") and "/" not in m.name[len("package/"):]:
88
+ target = tmp / m.name[len("package/"):]
89
+ with t.extractfile(m) as src, open(target, "wb") as dst:
90
+ shutil.copyfileobj(src, dst)
91
+ os.chmod(target, 0o755 if m.mode & 0o111 else 0o644)
92
+ if not (tmp / exe).exists():
93
+ return None, False, f"the package has no {exe}"
94
+ shutil.rmtree(home, ignore_errors=True)
95
+ tmp.rename(home)
96
+ return home, True, ""
97
+ except Exception as e: # noqa: BLE001 — a computer without it still serves
98
+ return None, False, str(e) or e.__class__.__name__
99
+ finally:
100
+ shutil.rmtree(tmp, ignore_errors=True)
101
+
102
+
103
+ def instructions_config(config_dir: Path) -> dict:
104
+ f = Path(config_dir) / INSTRUCTIONS_NAME
105
+ try:
106
+ same = f.read_text(encoding="utf-8") == INSTRUCTIONS
107
+ except (OSError, UnicodeDecodeError):
108
+ same = False
109
+ if not same:
110
+ f.parent.mkdir(parents=True, exist_ok=True)
111
+ f.write_text(INSTRUCTIONS, encoding="utf-8", newline="")
112
+ return {"instructions": [str(f)]}
113
+
114
+
115
+ def code_tools(config_dir: Path, env: dict | None = None, **kw) -> tuple[dict, dict, str]:
116
+ """→ (env additions, config block, a line for the log or "")."""
117
+ env = os.environ if env is None else env
118
+ home, fetched, why = ensure_ast_grep(env, **kw)
119
+ add = dict(LSP_TOOL_ENV)
120
+ config: dict = {}
121
+ if home:
122
+ add["PATH"] = f"{home}{os.pathsep}{env.get('PATH', '')}"
123
+ try:
124
+ config = instructions_config(config_dir)
125
+ except OSError:
126
+ pass
127
+ note = (f"witbitz-code: fetched ast-grep {AST_GREP_VERSION} into {home} (pinned SHA-256)" if fetched else "") if home else f"witbitz-code: no ast-grep ({why}) — the agent works without it"
128
+ return add, config, note
@@ -0,0 +1,25 @@
1
+ """gzip codec for synced account docs — a port of spaces/public/compress.js.
2
+
3
+ Large index docs are wrapped { z: <gzip, standard base64> } to dodge the store's 413 ceiling; small docs stay PLAIN so any
4
+ reader unwraps transparently. The compressed bytes need not match the browser's, only open there — the tests prove both
5
+ directions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gzip
11
+ from typing import Any
12
+
13
+ from . import _js
14
+
15
+ HAS_GZIP = True
16
+ json_bytes = _js.json_bytes
17
+
18
+
19
+ def gzip_b64(obj: Any) -> str:
20
+ data = _js.stringify(None if obj is _js.UNDEFINED else obj).encode("utf-8")
21
+ return _js.btoa(gzip.compress(data, mtime=0))
22
+
23
+
24
+ def gunzip_b64(b64: str) -> Any:
25
+ return _js.parse(gzip.decompress(_js.atob(b64)).decode("utf-8-sig", "replace"))
@@ -0,0 +1,189 @@
1
+ """The account's PAIRED COMPUTERS registry (docs/opencode-relay.md §3.1) — a port of spaces/public/codeComputers.js.
2
+
3
+ A sealed `op:'state'` doc named 'computers' in the account's index room. Every signed-in device reads it and the pairing
4
+ tool writes it, so there is ONE merge rule for all of them, defined in the JS module and reproduced here exactly:
5
+
6
+ { v: 1, computers: { <id>: { name, relay, secret, pairedAt, mod } | { removed: true, mod } } }
7
+
8
+ Last-writer-wins PER ENTRY on `mod`; on a tie a removal wins. A removal is a tombstone that keeps no secret, so an unpair
9
+ propagates instead of being re-added by a device that has not heard of it yet.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ import re
16
+ import time
17
+ from typing import Any
18
+ from urllib.parse import unquote
19
+
20
+ from . import _js
21
+
22
+ COMPUTERS_DOC = "computers"
23
+
24
+ _ID = re.compile(r"[A-Za-z0-9_-]{8,64}")
25
+ _SECRET = re.compile(r"[A-Za-z0-9_-]{43}")
26
+
27
+ # ── okRelay: `new URL(u)` is wss:, or ws: on localhost ────────────────────────────────────────────────────────────────
28
+ # A WHATWG-URL subset, enough to agree with the browser on what the registry may hold: scheme, authority, port, and the
29
+ # IPv4 shorthands (ws://127.1 IS 127.0.0.1 to a browser).
30
+ _FORBIDDEN_HOST = re.compile(r"[\x00-\x20#%/:<>?@\[\\\]^|\x7f]")
31
+
32
+
33
+ def _ipv4_number(part: str) -> int | None:
34
+ if part == "":
35
+ return None
36
+ try:
37
+ if re.fullmatch(r"0[xX][0-9a-fA-F]*", part):
38
+ return int(part[2:] or "0", 16)
39
+ if len(part) > 1 and part.startswith("0"):
40
+ return int(part[1:], 8) if re.fullmatch(r"[0-7]+", part[1:]) else None
41
+ return int(part) if re.fullmatch(r"[0-9]+", part) else None
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ def _parse_host(host: str) -> str | None:
47
+ """Host → its serialized form, or None when a browser would reject the URL."""
48
+ if host.startswith("["):
49
+ return host.lower() if re.fullmatch(r"\[[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*\]", host) else None
50
+ try:
51
+ host = unquote(host, errors="strict")
52
+ except UnicodeDecodeError:
53
+ return None
54
+ host = host.lower()
55
+ if host == "" or _FORBIDDEN_HOST.search(host):
56
+ return None
57
+ parts = host.split(".")
58
+ if parts[-1] == "" and len(parts) > 1:
59
+ parts = parts[:-1]
60
+ last = parts[-1]
61
+ if re.fullmatch(r"[0-9]+", last) or re.fullmatch(r"0[xX][0-9a-fA-F]*", last): # "ends in a number" → must be IPv4
62
+ nums = [_ipv4_number(p) for p in parts]
63
+ if len(parts) > 4 or any(n is None for n in nums) or any(n > 255 for n in nums[:-1]):
64
+ return None
65
+ if nums[-1] >= 256 ** (5 - len(nums)):
66
+ return None
67
+ value = sum(n * 256 ** (3 - i) for i, n in enumerate(nums[:-1])) + nums[-1]
68
+ return ".".join(str((value >> s) & 255) for s in (24, 16, 8, 0))
69
+ return host
70
+
71
+
72
+ def ok_relay(u: Any) -> bool:
73
+ s = _js.js_string(u)
74
+ s = re.sub(r"\A[\x00-\x20]+|[\x00-\x20]+\Z", "", s)
75
+ s = re.sub(r"[\t\n\r]", "", s)
76
+ m = re.match(r"([A-Za-z][A-Za-z0-9+.-]*):", s)
77
+ if not m:
78
+ return False
79
+ scheme = m.group(1).lower()
80
+ if scheme not in ("ws", "wss"):
81
+ return False
82
+ rest = re.sub(r"\A[/\\]*", "", s[m.end():]) # special schemes: any run of slashes or backslashes, even none
83
+ authority = re.split(r"[/\\?#]", rest, maxsplit=1)[0]
84
+ if "@" in authority:
85
+ authority = authority.rsplit("@", 1)[1]
86
+ hp = re.fullmatch(r"(\[[^\]]*\]|[^:]*)(?::([0-9]*))?", authority)
87
+ if not hp:
88
+ return False
89
+ if hp.group(2) and int(hp.group(2)) > 65535:
90
+ return False
91
+ host = _parse_host(hp.group(1))
92
+ if host is None:
93
+ return False
94
+ return scheme == "wss" or host in ("127.0.0.1", "localhost")
95
+
96
+
97
+ def _num(v: Any) -> float:
98
+ """A real number — not true, not "0x10", not [7]."""
99
+ return _js.clean_number(v) if _js.is_num(v) and math.isfinite(v) and v > 0 else 0
100
+
101
+
102
+ # ── the registry ──────────────────────────────────────────────────────────────────────────────────────────────────────
103
+ def norm_entry(e: Any) -> dict | None:
104
+ """One entry → its canonical shape, or None when it is not usable."""
105
+ if not isinstance(e, dict):
106
+ return None # (an array is an object to JS, but has no `mod`, so it ends here too)
107
+ get = e.get
108
+ mod = _num(get("mod", _js.UNDEFINED))
109
+ if not mod:
110
+ return None
111
+ if _js.truthy(get("removed", _js.UNDEFINED)):
112
+ return {"removed": True, "mod": mod}
113
+ secret, relay = get("secret", _js.UNDEFINED), get("relay", _js.UNDEFINED)
114
+ if not isinstance(secret, str) or not _SECRET.fullmatch(secret) or not ok_relay(relay):
115
+ return None
116
+ name = get("name", _js.UNDEFINED)
117
+ return {
118
+ # cut by character (code point, as Array.from), never mid-emoji
119
+ "name": "".join(list(_js.utf16_join([_js.js_string(name if _js.truthy(name) else "computer")]))[:80]),
120
+ "relay": _js.js_string(relay),
121
+ "secret": secret,
122
+ "pairedAt": _num(get("pairedAt", _js.UNDEFINED)) or mod,
123
+ "mod": mod,
124
+ }
125
+
126
+
127
+ def norm_registry(doc: Any) -> dict:
128
+ """Any doc (or nothing) → {v: 1, computers: {…}} with only well-formed entries."""
129
+ out: dict = {"v": 1, "computers": {}}
130
+ src = doc.get("computers") if isinstance(doc, dict) else None
131
+ if not isinstance(src, dict):
132
+ return out # (an array here has only index keys, which no id can match)
133
+ for cid, e in src.items():
134
+ # An id is only ever data, even one spelled like a built-in ("constructor", "__proto__") — the JS side keeps its
135
+ # entry maps prototype-free for exactly this.
136
+ if not isinstance(cid, str) or not _ID.fullmatch(cid):
137
+ continue
138
+ n = norm_entry(e)
139
+ if n:
140
+ out["computers"][cid] = n
141
+ return out
142
+
143
+
144
+ def merge_registry(a: Any, b: Any) -> dict:
145
+ """Merge two registries: per id the higher `mod` wins; a tie goes to the removal. Pure."""
146
+ x, y = norm_registry(a), norm_registry(b)
147
+ out = {"v": 1, "computers": dict(x["computers"])}
148
+ for cid, e in y["computers"].items():
149
+ cur = out["computers"].get(cid)
150
+ if cur is None or e["mod"] > cur["mod"] or (e["mod"] == cur["mod"] and e.get("removed") and not cur.get("removed")):
151
+ out["computers"][cid] = e
152
+ return out
153
+
154
+
155
+ def same_registry(a: Any, b: Any) -> bool:
156
+ return _js.stringify(_sort_keys(norm_registry(a))) == _js.stringify(_sort_keys(norm_registry(b)))
157
+
158
+
159
+ def _sort_keys(r: dict) -> dict:
160
+ return {"v": 1, "computers": {cid: r["computers"][cid] for cid in sorted(r["computers"])}}
161
+
162
+
163
+ def live_computers(reg: Any) -> list[dict]:
164
+ """The computers you can connect to, oldest pairing first: [{id, name, relay, secret}]."""
165
+ entries = [(cid, e) for cid, e in norm_registry(reg)["computers"].items() if not e.get("removed")]
166
+ entries.sort(key=lambda p: (p[1]["pairedAt"], p[0].encode("utf-16-be", "surrogatepass")))
167
+ return [{"id": cid, "name": e["name"], "relay": e["relay"], "secret": e["secret"]} for cid, e in entries]
168
+
169
+
170
+ def _now_ms() -> int:
171
+ return int(time.time() * 1000)
172
+
173
+
174
+ def with_computer(reg: Any, cid: str, entry: dict, now: float | None = None) -> dict:
175
+ """Add or update one computer (the pairing tool's write)."""
176
+ now = _now_ms() if now is None else now
177
+ prev = norm_registry(reg)["computers"].get(cid)
178
+ mod = max(now, prev["mod"] + 1 if prev else 0)
179
+ new = {k: entry[k] for k in ("name", "relay", "secret") if k in entry}
180
+ new["pairedAt"] = prev["pairedAt"] if prev and not prev.get("removed") else now
181
+ new["mod"] = mod
182
+ return merge_registry(reg, {"computers": {cid: new}})
183
+
184
+
185
+ def without_computer(reg: Any, cid: str, now: float | None = None) -> dict:
186
+ """Tombstone one computer (unpair / "Remove" in Settings)."""
187
+ now = _now_ms() if now is None else now
188
+ prev = norm_registry(reg)["computers"].get(cid)
189
+ return merge_registry(reg, {"computers": {cid: {"removed": True, "mod": max(now, prev["mod"] + 1 if prev else 0)}}})