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/__init__.py +7 -0
- witbitz_code/__main__.py +3 -0
- witbitz_code/_js.py +312 -0
- witbitz_code/account.py +127 -0
- witbitz_code/asks.py +119 -0
- witbitz_code/attachments.py +266 -0
- witbitz_code/auto.py +561 -0
- witbitz_code/auto_runner.py +481 -0
- witbitz_code/cli.py +223 -0
- witbitz_code/codetools.py +128 -0
- witbitz_code/compress.py +25 -0
- witbitz_code/computers.py +189 -0
- witbitz_code/connector.py +818 -0
- witbitz_code/devicelink.py +159 -0
- witbitz_code/folders.py +250 -0
- witbitz_code/link.py +205 -0
- witbitz_code/net.py +67 -0
- witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code/outputs.py +115 -0
- witbitz_code/pair.py +222 -0
- witbitz_code/pairings.py +214 -0
- witbitz_code/plugins.py +140 -0
- witbitz_code/policy.py +48 -0
- witbitz_code/qr.py +59 -0
- witbitz_code/relay.py +783 -0
- witbitz_code/seen.py +49 -0
- witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0.dist-info/METADATA +145 -0
- witbitz_code-1.2.0.dist-info/RECORD +36 -0
- witbitz_code-1.2.0.dist-info/WHEEL +5 -0
- witbitz_code-1.2.0.dist-info/entry_points.txt +2 -0
- witbitz_code-1.2.0.dist-info/licenses/LICENSE +21 -0
- witbitz_code-1.2.0.dist-info/top_level.txt +1 -0
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
|
witbitz_code/compress.py
ADDED
|
@@ -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)}}})
|