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/seen.py ADDED
@@ -0,0 +1,49 @@
1
+ """What the person has seen in the Code session list, shared by their devices — spaces/public/codeUnread.js, in Python.
2
+
3
+ The owner: "When I switch devices I get green dots". The page kept the record per device, so a session read on the phone
4
+ was news on the laptop. The computer keeps one too: the connector answers SEEN_ROUTE (GET: its record; POST a device's
5
+ record: merged, and the merge back), and every device merges with it. The rules are codeUnread.js's normSeen/mergeSeen;
6
+ tests/test_connector_e2e.py runs the same sequence against both connectors.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import re
13
+ from typing import Any
14
+
15
+ SEEN_ROUTE = "/witbitz/seen"
16
+ _SEEN_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
17
+ SEEN_MAX = 5000
18
+
19
+
20
+ def _num(v: Any) -> bool:
21
+ return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) and v > 0
22
+
23
+
24
+ def _plain(v: float | int) -> float | int:
25
+ return int(v) if isinstance(v, float) and v.is_integer() else v # 9000.0 → 9000, as JSON.stringify writes it
26
+
27
+
28
+ def norm_seen(o: Any) -> dict:
29
+ """A record from the wire or the disk, checked: session ids, positive numbers, at most SEEN_MAX (the latest looks kept)."""
30
+ if not isinstance(o, dict):
31
+ return {"since": 0, "at": {}}
32
+ raw = o.get("at")
33
+ entries = [(k, _plain(v)) for k, v in (raw.items() if isinstance(raw, dict) else []) if isinstance(k, str) and _SEEN_ID.fullmatch(k) and _num(v)]
34
+ if len(entries) > SEEN_MAX:
35
+ entries = sorted(entries, key=lambda e: -e[1])[:SEEN_MAX]
36
+ since = o.get("since")
37
+ return {"since": _plain(since) if _num(since) else 0, "at": dict(entries)}
38
+
39
+
40
+ def merge_seen(a: dict, b: dict) -> dict:
41
+ """Two records → one: each session's LATER look, the EARLIER first look. The same `a` back when `b` adds nothing."""
42
+ since = min(a["since"], b["since"]) if a["since"] and b["since"] else (a["since"] or b["since"])
43
+ at = a["at"]
44
+ for sid, v in b["at"].items():
45
+ if not (sid in a["at"] and a["at"][sid] >= v):
46
+ if at is a["at"]:
47
+ at = dict(a["at"])
48
+ at[sid] = v
49
+ return a if since == a["since"] and at is a["at"] else {"since": since, "at": at}
@@ -0,0 +1,67 @@
1
+ """Which suggested tools this computer has — tools/code-tools-probe.mjs, in Python ("Set up this computer").
2
+
3
+ A PATH lookup, nothing else: no process is started, no model is asked, nothing is installed. The connector answers the
4
+ page's nonce-checked `{t:'tools'}` with this. The catalog is spaces/public/codeTools.js; tests/test_tools_probe.py holds
5
+ this copy of the binary names and the answers to the JavaScript ones.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import stat
12
+ import sys
13
+ from collections.abc import Callable, Mapping
14
+ from pathlib import Path
15
+
16
+ # (tool id, binaries — any one on PATH means installed), in the catalog's order.
17
+ TOOL_BINS: list[tuple[str, list[str]]] = [
18
+ ("git", ["git"]), ("ripgrep", ["rg"]), ("jq", ["jq"]), ("python", ["python3", "python"]), ("uv", ["uv"]),
19
+ ("ffmpeg", ["ffmpeg"]), ("whisper", ["whisper-cli", "whisper-cpp", "whisper"]), ("poppler", ["pdftotext"]),
20
+ ("qpdf", ["qpdf"]), ("tesseract", ["tesseract"]), ("pandoc", ["pandoc"]), ("imagemagick", ["magick", "convert"]),
21
+ ]
22
+ # Package managers, most preferred first per OS: (id the page knows, binary).
23
+ PACKAGE_MANAGERS: dict[str, list[tuple[str, str]]] = {
24
+ "darwin": [("brew", "brew"), ("port", "port")],
25
+ "linux": [("apt", "apt-get"), ("dnf", "dnf"), ("pacman", "pacman"), ("zypper", "zypper"), ("apk", "apk"), ("brew", "brew")],
26
+ "win32": [("winget", "winget"), ("choco", "choco"), ("scoop", "scoop")],
27
+ }
28
+ _EXTRA_POSIX = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/snap/bin", "/home/linuxbrew/.linuxbrew/bin"]
29
+ _EXTRA_HOME = [".local/bin", ".cargo/bin", "bin"]
30
+
31
+
32
+ def _is_executable(path: str) -> bool:
33
+ try:
34
+ st = os.stat(path)
35
+ except OSError:
36
+ return False
37
+ return stat.S_ISREG(st.st_mode) and os.access(path, os.X_OK)
38
+
39
+
40
+ def search_dirs(*, env: Mapping[str, str], platform: str, home: str) -> list[str]:
41
+ """The folders searched, in order, without repeats."""
42
+ win = platform == "win32"
43
+ raw = env.get("PATH") or env.get("Path") or ""
44
+ from_path = [d for d in raw.split(";" if win else ":") if d]
45
+ extra = [] if win else _EXTRA_POSIX + ([f"{home.rstrip('/')}/{d}" for d in _EXTRA_HOME] if home else [])
46
+ return list(dict.fromkeys(from_path + extra))
47
+
48
+
49
+ def probe_tools(*, env: Mapping[str, str] | None = None, platform: str | None = None, home: str | None = None,
50
+ is_executable: Callable[[str], bool] = _is_executable) -> dict:
51
+ env = os.environ if env is None else env
52
+ platform = sys.platform if platform is None else platform
53
+ home = str(Path.home()) if home is None else home
54
+ win = platform == "win32"
55
+ dirs = search_dirs(env=env, platform=platform, home=home)
56
+ exts = [""] + [e.lower() for e in (env.get("PATHEXT") or ".COM;.EXE;.BAT;.CMD").split(";") if e] if win else [""]
57
+
58
+ def join(d: str, b: str) -> str:
59
+ return f"{d.rstrip(chr(92) + '/')}\\{b}" if win else f"{d.rstrip('/')}/{b}"
60
+
61
+ def has(b: str) -> bool:
62
+ return any(is_executable(join(d, b + e)) for d in dirs for e in exts)
63
+
64
+ tools = {tid: any(has(b) for b in bins if not (win and b == "convert")) for tid, bins in TOOL_BINS} # Windows' convert.exe is not ImageMagick
65
+ os_id = platform if platform in PACKAGE_MANAGERS else ""
66
+ pm = next((pid for pid, b in PACKAGE_MANAGERS[os_id] if has(b)), "") if os_id else ""
67
+ return {"tools": tools, "platform": {"os": os_id, "pm": pm}}
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: witbitz-code
3
+ Version: 1.2.0
4
+ Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
+ Author: Witbitz
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://witbitz.chat/docs/opencode.md
8
+ Project-URL: Repository, https://github.com/witbitzchat/witbitz-code
9
+ Keywords: witbitz,opencode,relay,e2ee
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Environment :: Console
13
+ Classifier: Topic :: Software Development
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: cryptography>=41
18
+ Requires-Dist: websockets>=13
19
+ Requires-Dist: httpx>=0.25
20
+ Requires-Dist: segno>=1.5
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest>=8; extra == "test"
23
+ Dynamic: license-file
24
+
25
+ # witbitz-code
26
+
27
+ Use the **Code** section of Witbitz Spaces, on any device you're signed in on, to reach
28
+ [OpenCode](https://opencode.ai) running on your computer. It's end-to-end encrypted, needs no open port, and doesn't need
29
+ Tailscale.
30
+
31
+ This is the Python build of the `witbitz-code` tool. It speaks the same wire protocol, uses the same pairing file and the
32
+ same account registry as the single-file Node download (`node witbitz-code.mjs`), so either one can pair a computer and
33
+ the other can serve or unpair it.
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ pipx install witbitz-code # or: pip install witbitz-code
39
+ ```
40
+
41
+ Python 3.10 or newer. Dependencies: `cryptography`, `websockets`, `httpx`, `segno`.
42
+
43
+ You also need OpenCode itself: `npm install -g opencode-ai` or `curl -fsSL https://opencode.ai/install | bash`.
44
+
45
+ ## Use
46
+
47
+ ```sh
48
+ witbitz-code pair # shows a QR code: scan it in Spaces → Settings → Back up & recovery → Add a device
49
+ witbitz-code serve # starts OpenCode on 127.0.0.1:4096 (unless it is running) and the connector
50
+ ```
51
+
52
+ Then open Code in Spaces. Leave `serve` running (Ctrl-C stops it).
53
+
54
+ | command | what it does |
55
+ |---|---|
56
+ | `pair [--name <name>] [--port <n> \| --opencode-url <url>]` | Show the QR code. The account that scans it gets this computer. `--name` sets the label your devices show (default: the hostname). |
57
+ | `serve [--port <n>] [--no-opencode]` | Start OpenCode on `127.0.0.1:<n>` (default 4096) if nothing is listening there, then connect the pairings whose OpenCode is on that port. One OpenCode per port, one `serve` per OpenCode. |
58
+ | `status` | List this computer's pairings: name, account, OpenCode address and relay. Secrets are never printed. |
59
+ | `rotate [--account <email>]` | Replace the pairing secret(s) without scanning again, then restart `serve`. Devices pick up the new secret on their next sync. |
60
+ | `unpair [--account <email>]` | Remove this computer from an account. Every device drops it on its next sync. |
61
+ | `version`, `--help` | |
62
+
63
+ `pair`, `rotate` and `unpair` also accept `--dry-run`.
64
+
65
+ **More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
66
+ channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
67
+ all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
68
+ (another port, ideally another OS user) and pair that account with `--port`.
69
+
70
+ ## How it works
71
+
72
+ ```
73
+ phone / desktop (Code page) this computer
74
+ seal ▸ frames ◂ open ── wss ─▶ code-relay.witbitz.chat ◀─ wss ── witbitz-code serve ──▶ opencode (127.0.0.1)
75
+ ```
76
+
77
+ - **Both ends dial out** to `wss://code-relay.witbitz.chat`. Nothing listens on your network, and OpenCode never leaves
78
+ `127.0.0.1`.
79
+ - **Pairing** uses a device link: the QR code holds only an ephemeral public key. Your signed-in device seals the account
80
+ pointer to that key. The computer then mints a random 32-byte **secret** for this pairing and does two things with it:
81
+ - stores it in `~/.witbitz/code/pairings.json` (mode 0600);
82
+ - publishes it into the account's sealed `computers` registry, which every signed-in device reads.
83
+ - **Keys:** the relay channel id and two direction keys (page→computer and computer→page, AES-256-GCM) are derived from
84
+ the secret with HKDF-SHA256. A frame reflected back at its sender doesn't decrypt.
85
+ - **Every frame is sealed.** The relay forwards ciphertext it can't read, and the clear header (sender id, sequence
86
+ number) is authenticated. Receivers drop replays. Large bodies are split into parts below the relay's message size cap.
87
+ - **Replays across restarts:** each time the connector's socket opens, it announces a fresh random nonce inside its
88
+ sealed hello. Every request and subscription must carry that nonce, so a request recorded before a restart or
89
+ reconnect is refused (409) and never reaches OpenCode.
90
+ - **The connector only forwards what the Code page itself calls:** list and read sessions, send a message, abort, answer
91
+ a permission prompt, rename and delete. Every other request gets a 403 without touching OpenCode. OpenCode can run shell
92
+ commands, so a leaked secret must not unlock more than the page can do. The live event stream is forwarded only while
93
+ a device is watching.
94
+ - **The OpenCode password** lives in `~/.opencode-server.env` (created 0600 on first pair). The connector adds it to local
95
+ calls, and it never leaves the computer.
96
+
97
+ **Auto mode** (Manual · Accept edits · Plan · **Auto** in the Code composer's mode chip, per session) lets this computer answer OpenCode's permission
98
+ prompts while you are away:
99
+ - **Refused at once:** what is never safe, like `rm -rf ~`, `mkfs` or `dd` onto a disk. The agent is told why.
100
+ - **Allowed at once:** read-only commands that stay inside the project, and edits to ordinary files in it.
101
+ - **Everything else** goes to the session's own model, in a throw-away session that can use no tools. Only a clear,
102
+ low-severity allow runs. A refusal, "ask", an unclear answer or a timeout leaves the prompt for you.
103
+ - **Never "always":** each answer covers that one call.
104
+
105
+ Each decision is logged to `~/.witbitz/code/auto-log.jsonl` as a digest, never the command. The rules are shared with the
106
+ Node connector and tested against the same cases, so both decide alike.
107
+
108
+ **What is encrypted, and what isn't.** Requests, responses and events travel end to end. The relay operator sees a
109
+ pseudonymous channel id, IP addresses, connection times, and frame sizes and timing. That's the same class of metadata
110
+ the Spaces room store sees. Anyone holding a pairing secret can drive OpenCode within the allowlist until you run
111
+ `rotate`.
112
+
113
+ Design: `docs/opencode-relay.md` in the Witbitz repository.
114
+
115
+ ## Files and environment
116
+
117
+ | | default | override |
118
+ |---|---|---|
119
+ | pairings | `~/.witbitz/code/pairings.json` | `WITBITZ_CODE_PAIRINGS` |
120
+ | OpenCode password | `~/.opencode-server.env` (`OPENCODE_SERVER_PASSWORD=`) | `OPENCODE_ENV_FILE` |
121
+ | pairing QR, as SVG | `~/.witbitz-rc.link.svg` | |
122
+ | Auto mode state + decision log | `~/.witbitz/code/auto-<computer>.json`, `auto-log.jsonl` | `WITBITZ_CODE_AUTO_DIR` |
123
+ | account API | `https://api.witbitz.chat/v1/space` | `RC_BASE`, `RC_ORIGIN` |
124
+ | device-link origins | `https://spaces.witbitz.chat,https://witbitz-spaces.pages.dev` | `RC_LINK_ORIGIN` |
125
+
126
+ ## Development
127
+
128
+ ```sh
129
+ pip install -e '.[test]'
130
+ python -m pytest tests -q
131
+ ```
132
+
133
+ The tests hold this package to the JavaScript reference implementation. They run the modules in `spaces/public/` and
134
+ `tools/` under `node` (22 or newer), and they cover:
135
+
136
+ - the pinned HKDF vectors;
137
+ - frames sealed on either side opening on the other;
138
+ - chunking through surrogate pairs;
139
+ - the device-link reply and the backup keys;
140
+ - the registry merge rules, compared byte for byte;
141
+ - the pairing file in both directions;
142
+ - the gzip wrap of account docs;
143
+ - a connector of each language driven by a client of the other, through a local fake relay and a fake OpenCode.
144
+
145
+ Without the repository checkout (`WITBITZ_REPO`) or `node`, the cross-implementation tests are skipped.
@@ -0,0 +1,36 @@
1
+ witbitz_code/__init__.py,sha256=Ip38w5d2nF2_LLuLFtZyq9dkyXe6n-bXbAHfcpedcFg,290
2
+ witbitz_code/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
3
+ witbitz_code/_js.py,sha256=56cKC97SfM1UZAcOGzGnL-SgIB4SfdAlqVDXX3Vi-N0,11458
4
+ witbitz_code/account.py,sha256=JoCbG1H-qK0hrh2PyNTFYoPpaHukuB8zfD_xaE6lsjA,6133
5
+ witbitz_code/asks.py,sha256=PuuM_WItEim4gKyn4tY9uEo3u_ejmcmwcJAYk2cziYs,5703
6
+ witbitz_code/attachments.py,sha256=1IxpI0YOTl-8x8AtPXwmXYMuxU5qip0ggoBo1I7rAS4,12409
7
+ witbitz_code/auto.py,sha256=lHwCzGOgQd42WUQTCCF4a-otEXaGQ9H4QDdPOJh1wtI,27609
8
+ witbitz_code/auto_runner.py,sha256=mvjlstcHetr-tmJttht4r9rEJ9Qps9VXangBxbGPCKA,26067
9
+ witbitz_code/cli.py,sha256=NCg0Cv3NscYcXHZrEtZKVbly44GNsYFWA6bJL-umHxM,9806
10
+ witbitz_code/codetools.py,sha256=MAZ3CkeHhHgaNa1Mi_nmL7eIC-uOroskdnSSVgomkCI,6758
11
+ witbitz_code/compress.py,sha256=ghZf_HTmYyHOwJDxDgJumr-7hIeohwio_SIMrzs1jtM,759
12
+ witbitz_code/computers.py,sha256=CJccZsL2Km4anrAbwKdhkENe1ZGXqOd_ou05k-gITDU,8280
13
+ witbitz_code/connector.py,sha256=JiK7m4qmH5pu7rgncQWhzfswAD5YghlNASri0ynJLSY,43313
14
+ witbitz_code/devicelink.py,sha256=v_xW6OkrdCJkkyvBtwuzmgc46uSngi7MiGeamiDN00g,7043
15
+ witbitz_code/folders.py,sha256=wi_WYJWNg6bhMfd9258BNsRH23hHwwoCTpFmqBbpSyc,10153
16
+ witbitz_code/link.py,sha256=UgLYh86NBX4m-FseRqo6Y90fpuKrz8AljxpJPN7njAI,8759
17
+ witbitz_code/net.py,sha256=YnqhwZPer4RADTtNIGdp02NXzI5v45fq9XsWOr-yLns,2062
18
+ witbitz_code/outputs.py,sha256=Sibj4Gb6yNEgBQdM7DDlOXbZS93Wo0CxHz7Llzgmyvs,5808
19
+ witbitz_code/pair.py,sha256=OD-Kmy-UBSVUAHvmRH2ApP4H6mpPxUJ3GzNp8MRWN5M,9681
20
+ witbitz_code/pairings.py,sha256=tb9A3jfa4AHmJUsuApJGUPNlI4T8oX7oQry17BP0xcQ,9808
21
+ witbitz_code/plugins.py,sha256=c4soyUM-VSwcLSmvey2aEYNfuu8J6jrsZtAAv4YzS6I,6679
22
+ witbitz_code/policy.py,sha256=kuvtkdKRTE-wf7_nBdwCs9PQvq_n2m-BCVqG6B7Iz1Y,2014
23
+ witbitz_code/qr.py,sha256=Rd5sitzrdGNktHsn7ISGBs-OURtPHmEPbwqPNRXbTTs,2776
24
+ witbitz_code/relay.py,sha256=9pHVrqQj0c4kGGPmckHZLsIchdLtexC5zGUjm3IwWWg,34626
25
+ witbitz_code/seen.py,sha256=ltMM6RgOu9b2mdpGOgTKWGWME_gLHUs1UxJiY7n_AyI,2182
26
+ witbitz_code/tools_probe.py,sha256=fELe69rzJwpB0omek9twRHN3OOolky3wrKVXFk28VFg,3404
27
+ witbitz_code/opencode_plugins/notes-init.md,sha256=7SkhvxSFhKY0W1EZv4DIk34RzbpxoQbqub3f7DJs9c0,768
28
+ witbitz_code/opencode_plugins/witbitz-media-server.mjs,sha256=M-wEUBlDASM89txnCyYLMUYci6Ss5du14fmEx27TZvo,75529
29
+ witbitz_code/opencode_plugins/witbitz-notes.js,sha256=M8V0WZiJdPE78hcjHT3oo1aybbD513rMWlxbcDBOdzg,26661
30
+ witbitz_code/opencode_plugins/witbitz-progress.js,sha256=6J9B71eZqhXpWoCLm-hSBSrlTfflCmHnWHp9yhEItbQ,10349
31
+ witbitz_code-1.2.0.dist-info/licenses/LICENSE,sha256=tAdpEnopN3C7_Mtwt4FpOt54tdJcytanMIPmfjWQyX8,1064
32
+ witbitz_code-1.2.0.dist-info/METADATA,sha256=1x4pzOpJjfPv8FMBcS3IMs_y6IPyAobaPmwjA5uqX5Y,8041
33
+ witbitz_code-1.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
34
+ witbitz_code-1.2.0.dist-info/entry_points.txt,sha256=rjZTJptXZoB2fSJowQBgbsNiiTfsmJGXEwJNIYJHbu4,55
35
+ witbitz_code-1.2.0.dist-info/top_level.txt,sha256=_Q9iBQXeIhv110W9Do9iSt_V4RRTbWIFBIjEhN_hq94,13
36
+ witbitz_code-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ witbitz-code = witbitz_code.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Witbitz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ witbitz_code