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.
Files changed (57) hide show
  1. witbitz_code-1.2.0/LICENSE +21 -0
  2. witbitz_code-1.2.0/PKG-INFO +145 -0
  3. witbitz_code-1.2.0/README.md +121 -0
  4. witbitz_code-1.2.0/pyproject.toml +46 -0
  5. witbitz_code-1.2.0/setup.cfg +4 -0
  6. witbitz_code-1.2.0/src/witbitz_code/__init__.py +7 -0
  7. witbitz_code-1.2.0/src/witbitz_code/__main__.py +3 -0
  8. witbitz_code-1.2.0/src/witbitz_code/_js.py +312 -0
  9. witbitz_code-1.2.0/src/witbitz_code/account.py +127 -0
  10. witbitz_code-1.2.0/src/witbitz_code/asks.py +119 -0
  11. witbitz_code-1.2.0/src/witbitz_code/attachments.py +266 -0
  12. witbitz_code-1.2.0/src/witbitz_code/auto.py +561 -0
  13. witbitz_code-1.2.0/src/witbitz_code/auto_runner.py +481 -0
  14. witbitz_code-1.2.0/src/witbitz_code/cli.py +223 -0
  15. witbitz_code-1.2.0/src/witbitz_code/codetools.py +128 -0
  16. witbitz_code-1.2.0/src/witbitz_code/compress.py +25 -0
  17. witbitz_code-1.2.0/src/witbitz_code/computers.py +189 -0
  18. witbitz_code-1.2.0/src/witbitz_code/connector.py +818 -0
  19. witbitz_code-1.2.0/src/witbitz_code/devicelink.py +159 -0
  20. witbitz_code-1.2.0/src/witbitz_code/folders.py +250 -0
  21. witbitz_code-1.2.0/src/witbitz_code/link.py +205 -0
  22. witbitz_code-1.2.0/src/witbitz_code/net.py +67 -0
  23. witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/notes-init.md +8 -0
  24. witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +791 -0
  25. witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/witbitz-notes.js +356 -0
  26. witbitz_code-1.2.0/src/witbitz_code/opencode_plugins/witbitz-progress.js +120 -0
  27. witbitz_code-1.2.0/src/witbitz_code/outputs.py +115 -0
  28. witbitz_code-1.2.0/src/witbitz_code/pair.py +222 -0
  29. witbitz_code-1.2.0/src/witbitz_code/pairings.py +214 -0
  30. witbitz_code-1.2.0/src/witbitz_code/plugins.py +140 -0
  31. witbitz_code-1.2.0/src/witbitz_code/policy.py +48 -0
  32. witbitz_code-1.2.0/src/witbitz_code/qr.py +59 -0
  33. witbitz_code-1.2.0/src/witbitz_code/relay.py +783 -0
  34. witbitz_code-1.2.0/src/witbitz_code/seen.py +49 -0
  35. witbitz_code-1.2.0/src/witbitz_code/tools_probe.py +67 -0
  36. witbitz_code-1.2.0/src/witbitz_code.egg-info/PKG-INFO +145 -0
  37. witbitz_code-1.2.0/src/witbitz_code.egg-info/SOURCES.txt +55 -0
  38. witbitz_code-1.2.0/src/witbitz_code.egg-info/dependency_links.txt +1 -0
  39. witbitz_code-1.2.0/src/witbitz_code.egg-info/entry_points.txt +2 -0
  40. witbitz_code-1.2.0/src/witbitz_code.egg-info/requires.txt +7 -0
  41. witbitz_code-1.2.0/src/witbitz_code.egg-info/top_level.txt +1 -0
  42. witbitz_code-1.2.0/tests/test_asks.py +176 -0
  43. witbitz_code-1.2.0/tests/test_attachments.py +129 -0
  44. witbitz_code-1.2.0/tests/test_auto.py +631 -0
  45. witbitz_code-1.2.0/tests/test_cli.py +201 -0
  46. witbitz_code-1.2.0/tests/test_codetools.py +71 -0
  47. witbitz_code-1.2.0/tests/test_connector_e2e.py +842 -0
  48. witbitz_code-1.2.0/tests/test_devicelink.py +220 -0
  49. witbitz_code-1.2.0/tests/test_files_and_compress.py +205 -0
  50. witbitz_code-1.2.0/tests/test_folders.py +193 -0
  51. witbitz_code-1.2.0/tests/test_outputs.py +110 -0
  52. witbitz_code-1.2.0/tests/test_pair_flows.py +224 -0
  53. witbitz_code-1.2.0/tests/test_plugins.py +184 -0
  54. witbitz_code-1.2.0/tests/test_policy.py +42 -0
  55. witbitz_code-1.2.0/tests/test_registry.py +163 -0
  56. witbitz_code-1.2.0/tests/test_relay_codec.py +379 -0
  57. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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("_", "/"))