witbitz-code 1.2.0__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.2.0/LICENSE +21 -0
- witbitz_code-1.2.0/PKG-INFO +145 -0
- witbitz_code-1.2.0/README.md +121 -0
- witbitz_code-1.2.0/pyproject.toml +46 -0
- witbitz_code-1.2.0/setup.cfg +4 -0
- witbitz_code-1.2.0/src/witbitz_code/__init__.py +7 -0
- witbitz_code-1.2.0/src/witbitz_code/__main__.py +3 -0
- witbitz_code-1.2.0/src/witbitz_code/_js.py +312 -0
- witbitz_code-1.2.0/src/witbitz_code/account.py +127 -0
- witbitz_code-1.2.0/src/witbitz_code/asks.py +119 -0
- witbitz_code-1.2.0/src/witbitz_code/attachments.py +266 -0
- witbitz_code-1.2.0/src/witbitz_code/auto.py +561 -0
- witbitz_code-1.2.0/src/witbitz_code/auto_runner.py +481 -0
- witbitz_code-1.2.0/src/witbitz_code/cli.py +223 -0
- witbitz_code-1.2.0/src/witbitz_code/codetools.py +128 -0
- witbitz_code-1.2.0/src/witbitz_code/compress.py +25 -0
- witbitz_code-1.2.0/src/witbitz_code/computers.py +189 -0
- witbitz_code-1.2.0/src/witbitz_code/connector.py +818 -0
- witbitz_code-1.2.0/src/witbitz_code/devicelink.py +159 -0
- witbitz_code-1.2.0/src/witbitz_code/folders.py +250 -0
- witbitz_code-1.2.0/src/witbitz_code/link.py +205 -0
- witbitz_code-1.2.0/src/witbitz_code/net.py +67 -0
- witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/notes-init.md +8 -0
- witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
- witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
- witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
- witbitz_code-1.2.0/src/witbitz_code/outputs.py +115 -0
- witbitz_code-1.2.0/src/witbitz_code/pair.py +222 -0
- witbitz_code-1.2.0/src/witbitz_code/pairings.py +214 -0
- witbitz_code-1.2.0/src/witbitz_code/plugins.py +140 -0
- witbitz_code-1.2.0/src/witbitz_code/policy.py +48 -0
- witbitz_code-1.2.0/src/witbitz_code/qr.py +59 -0
- witbitz_code-1.2.0/src/witbitz_code/relay.py +783 -0
- witbitz_code-1.2.0/src/witbitz_code/seen.py +49 -0
- witbitz_code-1.2.0/src/witbitz_code/tools_probe.py +67 -0
- witbitz_code-1.2.0/src/witbitz_code.egg-info/PKG-INFO +145 -0
- witbitz_code-1.2.0/src/witbitz_code.egg-info/SOURCES.txt +55 -0
- witbitz_code-1.2.0/src/witbitz_code.egg-info/dependency_links.txt +1 -0
- witbitz_code-1.2.0/src/witbitz_code.egg-info/entry_points.txt +2 -0
- witbitz_code-1.2.0/src/witbitz_code.egg-info/requires.txt +7 -0
- witbitz_code-1.2.0/src/witbitz_code.egg-info/top_level.txt +1 -0
- witbitz_code-1.2.0/tests/test_asks.py +176 -0
- witbitz_code-1.2.0/tests/test_attachments.py +129 -0
- witbitz_code-1.2.0/tests/test_auto.py +631 -0
- witbitz_code-1.2.0/tests/test_cli.py +201 -0
- witbitz_code-1.2.0/tests/test_codetools.py +71 -0
- witbitz_code-1.2.0/tests/test_connector_e2e.py +842 -0
- witbitz_code-1.2.0/tests/test_devicelink.py +220 -0
- witbitz_code-1.2.0/tests/test_files_and_compress.py +205 -0
- witbitz_code-1.2.0/tests/test_folders.py +193 -0
- witbitz_code-1.2.0/tests/test_outputs.py +110 -0
- witbitz_code-1.2.0/tests/test_pair_flows.py +224 -0
- witbitz_code-1.2.0/tests/test_plugins.py +184 -0
- witbitz_code-1.2.0/tests/test_policy.py +42 -0
- witbitz_code-1.2.0/tests/test_registry.py +163 -0
- witbitz_code-1.2.0/tests/test_relay_codec.py +379 -0
- witbitz_code-1.2.0/tests/test_tools_probe.py +102 -0
|
@@ -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,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,121 @@
|
|
|
1
|
+
# witbitz-code
|
|
2
|
+
|
|
3
|
+
Use the **Code** section of Witbitz Spaces, on any device you're signed in on, to reach
|
|
4
|
+
[OpenCode](https://opencode.ai) running on your computer. It's end-to-end encrypted, needs no open port, and doesn't need
|
|
5
|
+
Tailscale.
|
|
6
|
+
|
|
7
|
+
This is the Python build of the `witbitz-code` tool. It speaks the same wire protocol, uses the same pairing file and the
|
|
8
|
+
same account registry as the single-file Node download (`node witbitz-code.mjs`), so either one can pair a computer and
|
|
9
|
+
the other can serve or unpair it.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pipx install witbitz-code # or: pip install witbitz-code
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Python 3.10 or newer. Dependencies: `cryptography`, `websockets`, `httpx`, `segno`.
|
|
18
|
+
|
|
19
|
+
You also need OpenCode itself: `npm install -g opencode-ai` or `curl -fsSL https://opencode.ai/install | bash`.
|
|
20
|
+
|
|
21
|
+
## Use
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
witbitz-code pair # shows a QR code: scan it in Spaces → Settings → Back up & recovery → Add a device
|
|
25
|
+
witbitz-code serve # starts OpenCode on 127.0.0.1:4096 (unless it is running) and the connector
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then open Code in Spaces. Leave `serve` running (Ctrl-C stops it).
|
|
29
|
+
|
|
30
|
+
| command | what it does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `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). |
|
|
33
|
+
| `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. |
|
|
34
|
+
| `status` | List this computer's pairings: name, account, OpenCode address and relay. Secrets are never printed. |
|
|
35
|
+
| `rotate [--account <email>]` | Replace the pairing secret(s) without scanning again, then restart `serve`. Devices pick up the new secret on their next sync. |
|
|
36
|
+
| `unpair [--account <email>]` | Remove this computer from an account. Every device drops it on its next sync. |
|
|
37
|
+
| `version`, `--help` | |
|
|
38
|
+
|
|
39
|
+
`pair`, `rotate` and `unpair` also accept `--dry-run`.
|
|
40
|
+
|
|
41
|
+
**More than one account.** Each account that scans the QR code gets its own pairing, with its own secret and relay
|
|
42
|
+
channel. OpenCode has no users, so every paired account reaches the same sessions, files and shell. That's fine when
|
|
43
|
+
all the accounts are yours. If a second account belongs to **another person**, run a separate OpenCode for them
|
|
44
|
+
(another port, ideally another OS user) and pair that account with `--port`.
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
phone / desktop (Code page) this computer
|
|
50
|
+
seal ▸ frames ◂ open ── wss ─▶ code-relay.witbitz.chat ◀─ wss ── witbitz-code serve ──▶ opencode (127.0.0.1)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **Both ends dial out** to `wss://code-relay.witbitz.chat`. Nothing listens on your network, and OpenCode never leaves
|
|
54
|
+
`127.0.0.1`.
|
|
55
|
+
- **Pairing** uses a device link: the QR code holds only an ephemeral public key. Your signed-in device seals the account
|
|
56
|
+
pointer to that key. The computer then mints a random 32-byte **secret** for this pairing and does two things with it:
|
|
57
|
+
- stores it in `~/.witbitz/code/pairings.json` (mode 0600);
|
|
58
|
+
- publishes it into the account's sealed `computers` registry, which every signed-in device reads.
|
|
59
|
+
- **Keys:** the relay channel id and two direction keys (page→computer and computer→page, AES-256-GCM) are derived from
|
|
60
|
+
the secret with HKDF-SHA256. A frame reflected back at its sender doesn't decrypt.
|
|
61
|
+
- **Every frame is sealed.** The relay forwards ciphertext it can't read, and the clear header (sender id, sequence
|
|
62
|
+
number) is authenticated. Receivers drop replays. Large bodies are split into parts below the relay's message size cap.
|
|
63
|
+
- **Replays across restarts:** each time the connector's socket opens, it announces a fresh random nonce inside its
|
|
64
|
+
sealed hello. Every request and subscription must carry that nonce, so a request recorded before a restart or
|
|
65
|
+
reconnect is refused (409) and never reaches OpenCode.
|
|
66
|
+
- **The connector only forwards what the Code page itself calls:** list and read sessions, send a message, abort, answer
|
|
67
|
+
a permission prompt, rename and delete. Every other request gets a 403 without touching OpenCode. OpenCode can run shell
|
|
68
|
+
commands, so a leaked secret must not unlock more than the page can do. The live event stream is forwarded only while
|
|
69
|
+
a device is watching.
|
|
70
|
+
- **The OpenCode password** lives in `~/.opencode-server.env` (created 0600 on first pair). The connector adds it to local
|
|
71
|
+
calls, and it never leaves the computer.
|
|
72
|
+
|
|
73
|
+
**Auto mode** (Manual · Accept edits · Plan · **Auto** in the Code composer's mode chip, per session) lets this computer answer OpenCode's permission
|
|
74
|
+
prompts while you are away:
|
|
75
|
+
- **Refused at once:** what is never safe, like `rm -rf ~`, `mkfs` or `dd` onto a disk. The agent is told why.
|
|
76
|
+
- **Allowed at once:** read-only commands that stay inside the project, and edits to ordinary files in it.
|
|
77
|
+
- **Everything else** goes to the session's own model, in a throw-away session that can use no tools. Only a clear,
|
|
78
|
+
low-severity allow runs. A refusal, "ask", an unclear answer or a timeout leaves the prompt for you.
|
|
79
|
+
- **Never "always":** each answer covers that one call.
|
|
80
|
+
|
|
81
|
+
Each decision is logged to `~/.witbitz/code/auto-log.jsonl` as a digest, never the command. The rules are shared with the
|
|
82
|
+
Node connector and tested against the same cases, so both decide alike.
|
|
83
|
+
|
|
84
|
+
**What is encrypted, and what isn't.** Requests, responses and events travel end to end. The relay operator sees a
|
|
85
|
+
pseudonymous channel id, IP addresses, connection times, and frame sizes and timing. That's the same class of metadata
|
|
86
|
+
the Spaces room store sees. Anyone holding a pairing secret can drive OpenCode within the allowlist until you run
|
|
87
|
+
`rotate`.
|
|
88
|
+
|
|
89
|
+
Design: `docs/opencode-relay.md` in the Witbitz repository.
|
|
90
|
+
|
|
91
|
+
## Files and environment
|
|
92
|
+
|
|
93
|
+
| | default | override |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| pairings | `~/.witbitz/code/pairings.json` | `WITBITZ_CODE_PAIRINGS` |
|
|
96
|
+
| OpenCode password | `~/.opencode-server.env` (`OPENCODE_SERVER_PASSWORD=`) | `OPENCODE_ENV_FILE` |
|
|
97
|
+
| pairing QR, as SVG | `~/.witbitz-rc.link.svg` | |
|
|
98
|
+
| Auto mode state + decision log | `~/.witbitz/code/auto-<computer>.json`, `auto-log.jsonl` | `WITBITZ_CODE_AUTO_DIR` |
|
|
99
|
+
| account API | `https://api.witbitz.chat/v1/space` | `RC_BASE`, `RC_ORIGIN` |
|
|
100
|
+
| device-link origins | `https://spaces.witbitz.chat,https://witbitz-spaces.pages.dev` | `RC_LINK_ORIGIN` |
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
pip install -e '.[test]'
|
|
106
|
+
python -m pytest tests -q
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The tests hold this package to the JavaScript reference implementation. They run the modules in `spaces/public/` and
|
|
110
|
+
`tools/` under `node` (22 or newer), and they cover:
|
|
111
|
+
|
|
112
|
+
- the pinned HKDF vectors;
|
|
113
|
+
- frames sealed on either side opening on the other;
|
|
114
|
+
- chunking through surrogate pairs;
|
|
115
|
+
- the device-link reply and the backup keys;
|
|
116
|
+
- the registry merge rules, compared byte for byte;
|
|
117
|
+
- the pairing file in both directions;
|
|
118
|
+
- the gzip wrap of account docs;
|
|
119
|
+
- a connector of each language driven by a client of the other, through a local fake relay and a fake OpenCode.
|
|
120
|
+
|
|
121
|
+
Without the repository checkout (`WITBITZ_REPO`) or `node`, the cross-implementation tests are skipped.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "witbitz-code"
|
|
7
|
+
version = "1.2.0"
|
|
8
|
+
description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Witbitz" }]
|
|
14
|
+
keywords = ["witbitz", "opencode", "relay", "e2ee"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Topic :: Software Development",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"cryptography>=41",
|
|
23
|
+
"websockets>=13",
|
|
24
|
+
"httpx>=0.25",
|
|
25
|
+
"segno>=1.5",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
test = ["pytest>=8"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Documentation = "https://witbitz.chat/docs/opencode.md"
|
|
33
|
+
Repository = "https://github.com/witbitzchat/witbitz-code"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
witbitz-code = "witbitz_code.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
where = ["src"]
|
|
40
|
+
|
|
41
|
+
# Our OpenCode plugins, installed by `serve` (plugins.py) — copies of tools/opencode-plugins, kept current by embed.mjs
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
witbitz_code = ["opencode_plugins/*"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""witbitz-code: reach OpenCode on this computer from the Spaces Code section, end-to-end encrypted.
|
|
2
|
+
|
|
3
|
+
The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phase 5b), held to the JS one by shared test
|
|
4
|
+
vectors and cross-implementation tests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.2.0"
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""JavaScript semantics this port has to reproduce, in one place.
|
|
2
|
+
|
|
3
|
+
The JS tools are the reference implementation and the page runs them, so wherever the wire or a shared file depends on
|
|
4
|
+
a JS rule, Python follows the rule rather than its own idiom:
|
|
5
|
+
|
|
6
|
+
- JSON.stringify: compact, integral floats print as integers (1000, not 1000.0), lone UTF-16 surrogates are escaped,
|
|
7
|
+
integer-like object keys come first. Byte-identical output keeps the pairings file and the gzip-size threshold equal.
|
|
8
|
+
- String lengths and slices count UTF-16 code units (chunking at 192 KiB, `name.slice(0, 80)`, `id.slice(0, 64)`). A
|
|
9
|
+
slice may split a surrogate pair exactly as JS does; joining the halves back recombines it.
|
|
10
|
+
- Truthiness, unary `+`, `String(x)`, `Number.isInteger`: the registry and payload readers lean on all of them.
|
|
11
|
+
- atob's "forgiving base64": what the page accepts, Python accepts, and nothing more.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import decimal
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Undefined:
|
|
27
|
+
"""A property that is absent (JS `undefined`), as opposed to JSON null (None)."""
|
|
28
|
+
|
|
29
|
+
_inst = None
|
|
30
|
+
|
|
31
|
+
def __new__(cls):
|
|
32
|
+
if cls._inst is None:
|
|
33
|
+
cls._inst = super().__new__(cls)
|
|
34
|
+
return cls._inst
|
|
35
|
+
|
|
36
|
+
def __bool__(self) -> bool:
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def __repr__(self) -> str:
|
|
40
|
+
return "undefined"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
UNDEFINED = _Undefined()
|
|
44
|
+
|
|
45
|
+
# ECMAScript WhiteSpace + LineTerminator — what `\s` and String.prototype.trim() mean in JS (Python's differ: no U+FEFF).
|
|
46
|
+
WS = "\t\n\x0b\x0c\r \xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
|
|
47
|
+
# JS `.` stops at these; Python's `.` stops only at \n.
|
|
48
|
+
NOT_LT = "[^\n\r\u2028\u2029]"
|
|
49
|
+
_TRIM = re.compile(f"\\A[{WS}]+|[{WS}]+\\Z")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_num(v: Any) -> bool:
|
|
53
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def truthy(v: Any) -> bool:
|
|
57
|
+
if v is None or v is False or v is UNDEFINED:
|
|
58
|
+
return False
|
|
59
|
+
if is_num(v):
|
|
60
|
+
return v == v and v != 0
|
|
61
|
+
if isinstance(v, str):
|
|
62
|
+
return v != ""
|
|
63
|
+
return True # objects and arrays, even empty ones
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_integer(v: Any) -> bool:
|
|
67
|
+
"""Number.isInteger — 42.0 from JSON counts, exactly as JSON.parse("42.0") === 42 in JS."""
|
|
68
|
+
if not is_num(v):
|
|
69
|
+
return False
|
|
70
|
+
return isinstance(v, int) or (math.isfinite(v) and float(v).is_integer())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_safe_integer(v: Any) -> bool:
|
|
74
|
+
return is_integer(v) and abs(v) <= MAX_SAFE_INTEGER
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def strict_eq(a: Any, b: Any) -> bool:
|
|
78
|
+
"""`===` for JSON values: same kind and equal; objects by identity."""
|
|
79
|
+
if is_num(a) and is_num(b):
|
|
80
|
+
return a == b
|
|
81
|
+
if isinstance(a, str) and isinstance(b, str):
|
|
82
|
+
return a == b
|
|
83
|
+
if isinstance(a, bool) and isinstance(b, bool):
|
|
84
|
+
return a == b
|
|
85
|
+
return a is b
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def trim(s: str) -> str:
|
|
89
|
+
return _TRIM.sub("", s)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── numbers ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
93
|
+
_DEC = re.compile(r"[+-]?(?:Infinity|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _string_to_number(s: str) -> float:
|
|
97
|
+
t = trim(s)
|
|
98
|
+
if t == "":
|
|
99
|
+
return 0
|
|
100
|
+
if _DEC.fullmatch(t):
|
|
101
|
+
return float(t.replace("Infinity", "inf"))
|
|
102
|
+
for prefix, base, digits in (("0x", 16, "[0-9a-fA-F]"), ("0o", 8, "[0-7]"), ("0b", 2, "[01]")):
|
|
103
|
+
if re.fullmatch(f"0[{prefix[1]}{prefix[1].upper()}]{digits}+", t):
|
|
104
|
+
return float(int(t[2:], base))
|
|
105
|
+
return math.nan
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def to_number(v: Any) -> float:
|
|
109
|
+
"""Unary `+v`."""
|
|
110
|
+
if v is UNDEFINED:
|
|
111
|
+
return math.nan
|
|
112
|
+
if v is None or v is False:
|
|
113
|
+
return 0
|
|
114
|
+
if v is True:
|
|
115
|
+
return 1
|
|
116
|
+
if is_num(v):
|
|
117
|
+
return v
|
|
118
|
+
if isinstance(v, str):
|
|
119
|
+
return _string_to_number(v)
|
|
120
|
+
if isinstance(v, (list, tuple)):
|
|
121
|
+
return _string_to_number(js_string(v))
|
|
122
|
+
return math.nan
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def number_str(x: float) -> str:
|
|
126
|
+
"""Number.prototype.toString() — shortest round-trip digits, JS exponent rules."""
|
|
127
|
+
if isinstance(x, int) and abs(x) <= MAX_SAFE_INTEGER:
|
|
128
|
+
return str(x)
|
|
129
|
+
x = float(x)
|
|
130
|
+
if x != x:
|
|
131
|
+
return "NaN"
|
|
132
|
+
if math.isinf(x):
|
|
133
|
+
return "Infinity" if x > 0 else "-Infinity"
|
|
134
|
+
if x == 0:
|
|
135
|
+
return "0"
|
|
136
|
+
sign = "-" if x < 0 else ""
|
|
137
|
+
d = decimal.Decimal(repr(abs(x))).normalize()
|
|
138
|
+
_, digits, exp = d.as_tuple()
|
|
139
|
+
s = "".join(map(str, digits))
|
|
140
|
+
k, n = len(s), len(s) + exp
|
|
141
|
+
if k <= n <= 21:
|
|
142
|
+
return sign + s + "0" * (n - k)
|
|
143
|
+
if 0 < n <= 21:
|
|
144
|
+
return sign + s[:n] + "." + s[n:]
|
|
145
|
+
if -6 < n <= 0:
|
|
146
|
+
return sign + "0." + "0" * (-n) + s
|
|
147
|
+
e = n - 1
|
|
148
|
+
mant = s if k == 1 else s[0] + "." + s[1:]
|
|
149
|
+
return f"{sign}{mant}e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def js_string(v: Any) -> str:
|
|
153
|
+
"""String(v)."""
|
|
154
|
+
if v is UNDEFINED:
|
|
155
|
+
return "undefined"
|
|
156
|
+
if v is None:
|
|
157
|
+
return "null"
|
|
158
|
+
if v is True:
|
|
159
|
+
return "true"
|
|
160
|
+
if v is False:
|
|
161
|
+
return "false"
|
|
162
|
+
if is_num(v):
|
|
163
|
+
return number_str(v)
|
|
164
|
+
if isinstance(v, str):
|
|
165
|
+
return v
|
|
166
|
+
if isinstance(v, (list, tuple)):
|
|
167
|
+
return ",".join("" if e is None or e is UNDEFINED else js_string(e) for e in v)
|
|
168
|
+
return "[object Object]"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def clean_number(v: float) -> float:
|
|
172
|
+
"""A computed number as JS would hold it for JSON: integral floats become ints (so they print as 5, not 5.0)."""
|
|
173
|
+
if isinstance(v, float) and math.isfinite(v) and v.is_integer() and abs(v) <= MAX_SAFE_INTEGER:
|
|
174
|
+
return int(v)
|
|
175
|
+
return v
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── UTF-16 ────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
def utf16_len(s: str) -> int:
|
|
180
|
+
return len(s.encode("utf-16-le", "surrogatepass")) // 2
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def utf16_slice(s: str, start: int, end: int | None = None) -> str:
|
|
184
|
+
b = s.encode("utf-16-le", "surrogatepass")
|
|
185
|
+
return b[2 * start : None if end is None else 2 * end].decode("utf-16-le", "surrogatepass")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def utf16_join(parts) -> str:
|
|
189
|
+
"""''.join, then fuse surrogate halves that a UTF-16 slice split — JS strings do this implicitly."""
|
|
190
|
+
s = "".join(parts)
|
|
191
|
+
if not _SURROGATE.search(s):
|
|
192
|
+
return s
|
|
193
|
+
return s.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "surrogatepass")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def utf8(s: str) -> bytes:
|
|
200
|
+
"""A JS string as UTF-8 the way fetch/TextEncoder send it: pairs fused, a lone surrogate becomes U+FFFD."""
|
|
201
|
+
return _SURROGATE.sub("\ufffd", utf16_join([s])).encode("utf-8")
|
|
202
|
+
|
|
203
|
+
# ── JSON ──────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
205
|
+
_NEEDS_ESCAPE = re.compile('["\\\\\x00-\x1f\ud800-\udfff]')
|
|
206
|
+
_INDEX_KEY = re.compile(r"0|[1-9]\d*")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _quote(s: str) -> str:
|
|
210
|
+
if _SURROGATE.search(s):
|
|
211
|
+
s = utf16_join([s]) # a pair held as two code points is one character to JS
|
|
212
|
+
return '"' + _NEEDS_ESCAPE.sub(lambda m: _ESCAPES.get(m.group(0)) or "\\u%04x" % ord(m.group(0)), s) + '"'
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _ordered_keys(d: dict) -> list:
|
|
216
|
+
keys = [k if isinstance(k, str) else js_string(k) for k in d]
|
|
217
|
+
idx = sorted((k for k in keys if _INDEX_KEY.fullmatch(k) and int(k) < 2**32 - 1), key=int)
|
|
218
|
+
idx_set = set(idx)
|
|
219
|
+
return idx + [k for k in keys if k not in idx_set]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def stringify(v: Any, indent: int | None = None) -> str:
|
|
223
|
+
"""JSON.stringify(v, null, indent) for JSON-shaped values."""
|
|
224
|
+
gap = " " * indent if indent else ""
|
|
225
|
+
|
|
226
|
+
def ser(x: Any, cur: str) -> str | None:
|
|
227
|
+
if x is None:
|
|
228
|
+
return "null"
|
|
229
|
+
if x is True:
|
|
230
|
+
return "true"
|
|
231
|
+
if x is False:
|
|
232
|
+
return "false"
|
|
233
|
+
if x is UNDEFINED:
|
|
234
|
+
return None
|
|
235
|
+
if is_num(x):
|
|
236
|
+
return number_str(x) if math.isfinite(x) else "null"
|
|
237
|
+
if isinstance(x, str):
|
|
238
|
+
return _quote(x)
|
|
239
|
+
inner = cur + gap
|
|
240
|
+
if isinstance(x, (list, tuple)):
|
|
241
|
+
items = [ser(e, inner) or "null" for e in x]
|
|
242
|
+
if not items:
|
|
243
|
+
return "[]"
|
|
244
|
+
if not gap:
|
|
245
|
+
return "[" + ",".join(items) + "]"
|
|
246
|
+
return "[\n" + inner + (",\n" + inner).join(items) + "\n" + cur + "]"
|
|
247
|
+
if isinstance(x, dict):
|
|
248
|
+
src = {(k if isinstance(k, str) else js_string(k)): val for k, val in x.items()}
|
|
249
|
+
members = []
|
|
250
|
+
for k in _ordered_keys(src):
|
|
251
|
+
sv = ser(src[k], inner)
|
|
252
|
+
if sv is not None:
|
|
253
|
+
members.append(_quote(k) + (": " if gap else ":") + sv)
|
|
254
|
+
if not members:
|
|
255
|
+
return "{}"
|
|
256
|
+
if not gap:
|
|
257
|
+
return "{" + ",".join(members) + "}"
|
|
258
|
+
return "{\n" + inner + (",\n" + inner).join(members) + "\n" + cur + "}"
|
|
259
|
+
raise TypeError(f"not JSON-serializable: {type(x).__name__}")
|
|
260
|
+
|
|
261
|
+
out = ser(v, "")
|
|
262
|
+
if out is None:
|
|
263
|
+
raise TypeError("undefined is not JSON")
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _no_constants(name: str):
|
|
268
|
+
raise ValueError(f"{name} is not JSON")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def parse(text: str) -> Any:
|
|
272
|
+
"""JSON.parse — refuses NaN/Infinity, which Python's json would otherwise accept."""
|
|
273
|
+
return json.loads(text, parse_constant=_no_constants)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def json_bytes(v: Any) -> float:
|
|
277
|
+
"""UTF-8 size of JSON.stringify(v ?? null) — Infinity when it cannot be serialized (compress.js _jsonBytes)."""
|
|
278
|
+
try:
|
|
279
|
+
return len(stringify(None if v is UNDEFINED else v).encode("utf-8"))
|
|
280
|
+
except (TypeError, ValueError, RecursionError):
|
|
281
|
+
return math.inf
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── base64 ────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
_B64_BAD = re.compile(r"[^A-Za-z0-9+/]")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def atob(s: str) -> bytes:
|
|
289
|
+
"""WHATWG forgiving-base64 decode (what `atob` accepts). Raises ValueError where atob throws."""
|
|
290
|
+
t = re.sub(r"[\t\n\x0c\r ]", "", s)
|
|
291
|
+
if len(t) % 4 == 0:
|
|
292
|
+
if t.endswith("=="):
|
|
293
|
+
t = t[:-2]
|
|
294
|
+
elif t.endswith("="):
|
|
295
|
+
t = t[:-1]
|
|
296
|
+
if len(t) % 4 == 1 or _B64_BAD.search(t):
|
|
297
|
+
raise ValueError("invalid base64")
|
|
298
|
+
return base64.b64decode(t + "=" * (-len(t) % 4))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def btoa(b: bytes) -> str:
|
|
302
|
+
return base64.b64encode(b).decode("ascii")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def b64u(b: bytes) -> str:
|
|
306
|
+
"""base64url, unpadded (every B64/b64u/b64url helper on the JS side)."""
|
|
307
|
+
return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def unb64u_loose(s: Any) -> bytes:
|
|
311
|
+
"""deviceLink UNB64 / recovery fromB64url: url alphabet mapped back, then atob (no padding added)."""
|
|
312
|
+
return atob(js_string(s).replace("-", "+").replace("_", "/"))
|