voidcraft-world-bridge 0.1.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.
- voidcraft_world_bridge-0.1.0/LICENSE +21 -0
- voidcraft_world_bridge-0.1.0/PKG-INFO +132 -0
- voidcraft_world_bridge-0.1.0/README.md +111 -0
- voidcraft_world_bridge-0.1.0/pyproject.toml +50 -0
- voidcraft_world_bridge-0.1.0/pyproject.toml.orig +45 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/__init__.py +13 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/builtins.py +19 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/cli.py +111 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/guards.py +131 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/__init__.py +4 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/agents.py +31 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/jobs.py +147 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/models.py +101 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/ollama.py +146 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/openai_compat.py +172 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/plugin.py +365 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/runtimes.py +77 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/local_llm/transport.py +69 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/plugins.py +213 -0
- voidcraft_world_bridge-0.1.0/voidcraft_world_bridge/server.py +307 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VoidCraft
|
|
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,132 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: voidcraft-world-bridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A loopback server that lets voidcraft.world reach your machine — starting with your own local LLM
|
|
5
|
+
Keywords: local-llm,ollama,lm-studio,llama.cpp,voidcraft,bridge
|
|
6
|
+
Author: VoidCraft
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.10, <4
|
|
17
|
+
Project-URL: Homepage, https://voidcraft.world
|
|
18
|
+
Project-URL: Source, https://github.com/voidcraft-world/voidcraft-world-bridge
|
|
19
|
+
Project-URL: Issues, https://github.com/voidcraft-world/voidcraft-world-bridge/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# voidcraft-world-bridge
|
|
23
|
+
|
|
24
|
+
A small loopback server that lets [voidcraft.world](https://voidcraft.world) talk to
|
|
25
|
+
**your own local LLM**, so it can answer questions about your worlds or command a side in
|
|
26
|
+
Arena World.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uvx voidcraft-world-bridge
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That's the whole install. It finds your model server on its own and listens on
|
|
33
|
+
`http://127.0.0.1:7682` until you press Ctrl-C:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
voidcraft-world-bridge 0.1.0 — listening on http://127.0.0.1:7682
|
|
37
|
+
plugin: local-llm → http://127.0.0.1:7682/plugin/local-llm/…
|
|
38
|
+
local model: Ollama at http://127.0.0.1:11434 → qwen3.6:35b-a3b
|
|
39
|
+
Ctrl-C to stop
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Your model server
|
|
43
|
+
|
|
44
|
+
Run any one of these and the bridge finds it, asking in this order:
|
|
45
|
+
|
|
46
|
+
| Server | Where the bridge looks |
|
|
47
|
+
|---|---|
|
|
48
|
+
| [Ollama](https://ollama.com) | `http://127.0.0.1:11434` |
|
|
49
|
+
| [LM Studio](https://lmstudio.ai) (start its local server) | `http://127.0.0.1:1234/v1` |
|
|
50
|
+
| [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server` | `http://127.0.0.1:8080/v1` |
|
|
51
|
+
|
|
52
|
+
Anything else that speaks the OpenAI API (vLLM, Jan, a custom port) works too. Point the
|
|
53
|
+
bridge at it, and it asks only that server:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
VOIDCRAFT_LOCAL_LLM_URL=http://127.0.0.1:8000/v1 uvx voidcraft-world-bridge
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Which model answers:** the strongest chat model you have, by a built-in preference list
|
|
60
|
+
(Qwen 3.6 35B-A3B first). Embedding models are never picked. Pin one with
|
|
61
|
+
`VOIDCRAFT_LOCAL_LLM_MODEL=<name>`. The bridge never downloads a model; install one with your
|
|
62
|
+
server first (`ollama pull qwen3.5:9b` is a good small start).
|
|
63
|
+
|
|
64
|
+
**What the OpenAI-compatible path cannot do:** set the context size per request. Load your model
|
|
65
|
+
with enough context (16K or more) in the server itself.
|
|
66
|
+
|
|
67
|
+
## Why it exists
|
|
68
|
+
|
|
69
|
+
A web page cannot call `localhost` services directly: CORS and Chrome's Local Network
|
|
70
|
+
Access stop it, on purpose. The bridge is the one door through, and it is narrow:
|
|
71
|
+
|
|
72
|
+
- **Loopback only.** It binds `127.0.0.1`. There is no flag to bind anything wider.
|
|
73
|
+
- **Origin allowlist.** Only `voidcraft.world` and pages served from your own machine
|
|
74
|
+
may call it. Any other site gets a `403`.
|
|
75
|
+
- **DNS-rebinding guard.** A request addressed to any hostname other than `localhost` /
|
|
76
|
+
`127.0.0.1` / `[::1]` gets a `403`, even when the origin looks right.
|
|
77
|
+
- **No shell, no files.** It routes chat requests to your model server and nothing else.
|
|
78
|
+
- **Your prompts are not kept.** A request is dropped from memory once it is answered.
|
|
79
|
+
- **Stdlib only.** Zero third-party dependencies.
|
|
80
|
+
|
|
81
|
+
## The local-llm API
|
|
82
|
+
|
|
83
|
+
Mounted at `/plugin/local-llm/`:
|
|
84
|
+
|
|
85
|
+
| Route | Behaviour |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `GET /status` | Which server answered (`runtime`, `runtime_label`, `runtime_url`), whether it is `reachable`, the installed **chat** models, and `picked_model`. Never an error when no server runs; `reachable: false` is a normal state. |
|
|
88
|
+
| `POST /chat` | `{system, user}` or `{messages, tools?}`, plus `model?, max_tokens?, temperature?, format?, think?, num_ctx?, seed?` → **202** `{job_id}` at once. `400` on a bad body, `429` when 4 requests already wait. |
|
|
89
|
+
| `GET /job?id=` | `queued` → `running` → `done` (`result.text`, `result.tool_calls`, token counts, `total_ms`) or `failed` (`error.code`: `runtime_down` · `runtime_error` · `no_model` · `model_not_installed`). |
|
|
90
|
+
|
|
91
|
+
Generation is a job, not a request: one answer can take a minute, and one model generates one
|
|
92
|
+
answer at a time — two at once would only split the same memory bandwidth.
|
|
93
|
+
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
voidcraft-world-bridge # serve (foreground)
|
|
98
|
+
voidcraft-world-bridge status # is one running? what does it mount?
|
|
99
|
+
voidcraft-world-bridge --version
|
|
100
|
+
voidcraft-world-bridge --port 7700 # or VOIDCRAFT_BRIDGE_PORT=7700
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Plugins
|
|
104
|
+
|
|
105
|
+
Everything the bridge can do is a plugin mounted at `/plugin/<name>/…`. `local-llm` is built
|
|
106
|
+
in; add your own by pointing the bridge at a directory containing a `bridge_plugin.py`:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
VOIDCRAFT_BRIDGE_PLUGINS=/path/to/my-plugin voidcraft-world-bridge
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
or list directories in `~/.config/voidcraft-world-bridge/plugins.json`:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{ "version": 1, "plugins": ["/path/to/my-plugin"] }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A plugin module defines `PLUGIN_NAME` and `create_plugin(context)`, returning an object
|
|
119
|
+
with `routes()`, `handle_get(subpath, query)` and `handle_post(subpath, query, body)`.
|
|
120
|
+
Handlers return `(status, dict)` for JSON or `(status, bytes, content_type)` for a raw
|
|
121
|
+
page. A plugin that fails to load is skipped, and a handler that raises becomes a `500`;
|
|
122
|
+
a plugin can never take the bridge down. Full contract: `voidcraft_world_bridge/plugins.py`.
|
|
123
|
+
|
|
124
|
+
## Remote access (optional)
|
|
125
|
+
|
|
126
|
+
To reach the bridge through `tailscale serve`, name the machine explicitly:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
VOIDCRAFT_BRIDGE_ALLOWED_HOSTS=mymac.tailXXXX.ts.net voidcraft-world-bridge
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Never use `tailscale funnel` or a public tunnel: that publishes your bridge to the internet.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# voidcraft-world-bridge
|
|
2
|
+
|
|
3
|
+
A small loopback server that lets [voidcraft.world](https://voidcraft.world) talk to
|
|
4
|
+
**your own local LLM**, so it can answer questions about your worlds or command a side in
|
|
5
|
+
Arena World.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uvx voidcraft-world-bridge
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's the whole install. It finds your model server on its own and listens on
|
|
12
|
+
`http://127.0.0.1:7682` until you press Ctrl-C:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
voidcraft-world-bridge 0.1.0 — listening on http://127.0.0.1:7682
|
|
16
|
+
plugin: local-llm → http://127.0.0.1:7682/plugin/local-llm/…
|
|
17
|
+
local model: Ollama at http://127.0.0.1:11434 → qwen3.6:35b-a3b
|
|
18
|
+
Ctrl-C to stop
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Your model server
|
|
22
|
+
|
|
23
|
+
Run any one of these and the bridge finds it, asking in this order:
|
|
24
|
+
|
|
25
|
+
| Server | Where the bridge looks |
|
|
26
|
+
|---|---|
|
|
27
|
+
| [Ollama](https://ollama.com) | `http://127.0.0.1:11434` |
|
|
28
|
+
| [LM Studio](https://lmstudio.ai) (start its local server) | `http://127.0.0.1:1234/v1` |
|
|
29
|
+
| [llama.cpp](https://github.com/ggml-org/llama.cpp) `llama-server` | `http://127.0.0.1:8080/v1` |
|
|
30
|
+
|
|
31
|
+
Anything else that speaks the OpenAI API (vLLM, Jan, a custom port) works too. Point the
|
|
32
|
+
bridge at it, and it asks only that server:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
VOIDCRAFT_LOCAL_LLM_URL=http://127.0.0.1:8000/v1 uvx voidcraft-world-bridge
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Which model answers:** the strongest chat model you have, by a built-in preference list
|
|
39
|
+
(Qwen 3.6 35B-A3B first). Embedding models are never picked. Pin one with
|
|
40
|
+
`VOIDCRAFT_LOCAL_LLM_MODEL=<name>`. The bridge never downloads a model; install one with your
|
|
41
|
+
server first (`ollama pull qwen3.5:9b` is a good small start).
|
|
42
|
+
|
|
43
|
+
**What the OpenAI-compatible path cannot do:** set the context size per request. Load your model
|
|
44
|
+
with enough context (16K or more) in the server itself.
|
|
45
|
+
|
|
46
|
+
## Why it exists
|
|
47
|
+
|
|
48
|
+
A web page cannot call `localhost` services directly: CORS and Chrome's Local Network
|
|
49
|
+
Access stop it, on purpose. The bridge is the one door through, and it is narrow:
|
|
50
|
+
|
|
51
|
+
- **Loopback only.** It binds `127.0.0.1`. There is no flag to bind anything wider.
|
|
52
|
+
- **Origin allowlist.** Only `voidcraft.world` and pages served from your own machine
|
|
53
|
+
may call it. Any other site gets a `403`.
|
|
54
|
+
- **DNS-rebinding guard.** A request addressed to any hostname other than `localhost` /
|
|
55
|
+
`127.0.0.1` / `[::1]` gets a `403`, even when the origin looks right.
|
|
56
|
+
- **No shell, no files.** It routes chat requests to your model server and nothing else.
|
|
57
|
+
- **Your prompts are not kept.** A request is dropped from memory once it is answered.
|
|
58
|
+
- **Stdlib only.** Zero third-party dependencies.
|
|
59
|
+
|
|
60
|
+
## The local-llm API
|
|
61
|
+
|
|
62
|
+
Mounted at `/plugin/local-llm/`:
|
|
63
|
+
|
|
64
|
+
| Route | Behaviour |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `GET /status` | Which server answered (`runtime`, `runtime_label`, `runtime_url`), whether it is `reachable`, the installed **chat** models, and `picked_model`. Never an error when no server runs; `reachable: false` is a normal state. |
|
|
67
|
+
| `POST /chat` | `{system, user}` or `{messages, tools?}`, plus `model?, max_tokens?, temperature?, format?, think?, num_ctx?, seed?` → **202** `{job_id}` at once. `400` on a bad body, `429` when 4 requests already wait. |
|
|
68
|
+
| `GET /job?id=` | `queued` → `running` → `done` (`result.text`, `result.tool_calls`, token counts, `total_ms`) or `failed` (`error.code`: `runtime_down` · `runtime_error` · `no_model` · `model_not_installed`). |
|
|
69
|
+
|
|
70
|
+
Generation is a job, not a request: one answer can take a minute, and one model generates one
|
|
71
|
+
answer at a time — two at once would only split the same memory bandwidth.
|
|
72
|
+
|
|
73
|
+
## Commands
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
voidcraft-world-bridge # serve (foreground)
|
|
77
|
+
voidcraft-world-bridge status # is one running? what does it mount?
|
|
78
|
+
voidcraft-world-bridge --version
|
|
79
|
+
voidcraft-world-bridge --port 7700 # or VOIDCRAFT_BRIDGE_PORT=7700
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Plugins
|
|
83
|
+
|
|
84
|
+
Everything the bridge can do is a plugin mounted at `/plugin/<name>/…`. `local-llm` is built
|
|
85
|
+
in; add your own by pointing the bridge at a directory containing a `bridge_plugin.py`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
VOIDCRAFT_BRIDGE_PLUGINS=/path/to/my-plugin voidcraft-world-bridge
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
or list directories in `~/.config/voidcraft-world-bridge/plugins.json`:
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "version": 1, "plugins": ["/path/to/my-plugin"] }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A plugin module defines `PLUGIN_NAME` and `create_plugin(context)`, returning an object
|
|
98
|
+
with `routes()`, `handle_get(subpath, query)` and `handle_post(subpath, query, body)`.
|
|
99
|
+
Handlers return `(status, dict)` for JSON or `(status, bytes, content_type)` for a raw
|
|
100
|
+
page. A plugin that fails to load is skipped, and a handler that raises becomes a `500`;
|
|
101
|
+
a plugin can never take the bridge down. Full contract: `voidcraft_world_bridge/plugins.py`.
|
|
102
|
+
|
|
103
|
+
## Remote access (optional)
|
|
104
|
+
|
|
105
|
+
To reach the bridge through `tailscale serve`, name the machine explicitly:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
VOIDCRAFT_BRIDGE_ALLOWED_HOSTS=mymac.tailXXXX.ts.net voidcraft-world-bridge
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Never use `tailscale funnel` or a public tunnel: that publishes your bridge to the internet.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "voidcraft-world-bridge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A loopback server that lets voidcraft.world reach your machine — starting with your own local LLM"
|
|
5
|
+
requires-python = ">=3.10,<4"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"local-llm",
|
|
11
|
+
"ollama",
|
|
12
|
+
"lm-studio",
|
|
13
|
+
"llama.cpp",
|
|
14
|
+
"voidcraft",
|
|
15
|
+
"bridge",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "VoidCraft"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://voidcraft.world"
|
|
33
|
+
Source = "https://github.com/voidcraft-world/voidcraft-world-bridge"
|
|
34
|
+
Issues = "https://github.com/voidcraft-world/voidcraft-world-bridge/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
voidcraft-world-bridge = "voidcraft_world_bridge.cli:main"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest>=7.4.0,<8"]
|
|
41
|
+
|
|
42
|
+
[tool.uv]
|
|
43
|
+
default-groups = "all"
|
|
44
|
+
|
|
45
|
+
[tool.uv.build-backend]
|
|
46
|
+
module-root = ""
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
50
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "voidcraft-world-bridge"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A loopback server that lets voidcraft.world reach your machine — starting with your own local LLM"
|
|
5
|
+
requires-python = ">=3.10,<4"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "VoidCraft" }]
|
|
10
|
+
keywords = ["local-llm", "ollama", "lm-studio", "llama.cpp", "voidcraft", "bridge"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
# Stdlib only, on purpose — see voidcraft_world_bridge/server.py.
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://voidcraft.world"
|
|
25
|
+
Source = "https://github.com/voidcraft-world/voidcraft-world-bridge"
|
|
26
|
+
Issues = "https://github.com/voidcraft-world/voidcraft-world-bridge/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
voidcraft-world-bridge = "voidcraft_world_bridge.cli:main"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7.4.0,<8",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.uv]
|
|
37
|
+
default-groups = "all"
|
|
38
|
+
|
|
39
|
+
[tool.uv.build-backend]
|
|
40
|
+
# voidcraft_world_bridge/ lives at the project root (flat layout), not under src/
|
|
41
|
+
module-root = ""
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.11.26,<0.12.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""voidcraft-world-bridge: a loopback server that lets voidcraft.world reach your machine.
|
|
2
|
+
|
|
3
|
+
It mounts plugins under `/plugin/<name>/` behind a Host + Origin allowlist, so a
|
|
4
|
+
page on voidcraft.world can talk to something on this machine — a local language
|
|
5
|
+
model, first — while a page anywhere else cannot.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("voidcraft-world-bridge")
|
|
12
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
13
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""The plugins every bridge mounts without being told to.
|
|
2
|
+
|
|
3
|
+
Today that is `local-llm`: the reason a stranger runs this bridge at all is to
|
|
4
|
+
let voidcraft.world reach their own model, so it must work with zero config. A
|
|
5
|
+
plugin directory configured under the same name is skipped, never mounted twice
|
|
6
|
+
(`load_plugins`, `preloaded`).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from voidcraft_world_bridge.local_llm.plugin import PLUGIN_NAME as LOCAL_LLM, LocalLlmPlugin
|
|
13
|
+
from voidcraft_world_bridge.plugins import PluginContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def builtin_plugins(context: PluginContext, *, agent_runtimes: Iterable[object] = ()) -> dict[str, object]:
|
|
17
|
+
"""name → plugin for every built-in. `agent_runtimes` are handed to
|
|
18
|
+
`local-llm` (see `local_llm/agents.py`); a stock bridge registers none."""
|
|
19
|
+
return {LOCAL_LLM: LocalLlmPlugin(context, agent_runtimes=agent_runtimes)}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""`voidcraft-world-bridge` — run the bridge in the foreground, or ask one how it is.
|
|
2
|
+
|
|
3
|
+
voidcraft-world-bridge serve on 127.0.0.1:7682 until Ctrl-C
|
|
4
|
+
voidcraft-world-bridge status is one running here, and what does it mount?
|
|
5
|
+
voidcraft-world-bridge --version
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from voidcraft_world_bridge import __version__
|
|
18
|
+
from voidcraft_world_bridge.builtins import LOCAL_LLM, builtin_plugins
|
|
19
|
+
from voidcraft_world_bridge.plugins import PluginContext, discover_plugin_dirs, load_plugins
|
|
20
|
+
from voidcraft_world_bridge.server import DEFAULT_PORT, LOOPBACK, NAME, build_handler, make_server
|
|
21
|
+
|
|
22
|
+
PORT_ENV = "VOIDCRAFT_BRIDGE_PORT"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _log(message: str) -> None:
|
|
26
|
+
print(message, flush=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_port() -> int:
|
|
30
|
+
raw = os.environ.get(PORT_ENV)
|
|
31
|
+
try:
|
|
32
|
+
return int(raw) if raw else DEFAULT_PORT
|
|
33
|
+
except ValueError:
|
|
34
|
+
return DEFAULT_PORT
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(port: int) -> int:
|
|
38
|
+
"""Load plugins, bind loopback, serve until Ctrl-C. Exit code: 0 on Ctrl-C,
|
|
39
|
+
1 when the port is taken."""
|
|
40
|
+
context = PluginContext(status_port=port, log=_log)
|
|
41
|
+
plugins = load_plugins(discover_plugin_dirs(os.environ), context, preloaded=builtin_plugins(context))
|
|
42
|
+
try:
|
|
43
|
+
server = make_server(port, build_handler(plugins))
|
|
44
|
+
except OSError as err:
|
|
45
|
+
_log(f"{NAME}: cannot listen on {LOOPBACK}:{port} ({err.strerror or err}).")
|
|
46
|
+
_log(f" Is another bridge already running? Check with: {NAME} status")
|
|
47
|
+
return 1
|
|
48
|
+
_log(f"{NAME} {__version__} — listening on http://{LOOPBACK}:{port}")
|
|
49
|
+
for name in plugins:
|
|
50
|
+
_log(f" plugin: {name} → http://{LOOPBACK}:{port}/plugin/{name}/…")
|
|
51
|
+
if LOCAL_LLM in plugins:
|
|
52
|
+
_log(f" local model: {describe_local_model(plugins[LOCAL_LLM])}")
|
|
53
|
+
_log(" Ctrl-C to stop")
|
|
54
|
+
try:
|
|
55
|
+
server.serve_forever()
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
_log(f"\n{NAME}: stopped")
|
|
58
|
+
finally:
|
|
59
|
+
server.server_close()
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def describe_local_model(plugin) -> str:
|
|
64
|
+
"""One line on what `local-llm` found, read through its own `/status` route,
|
|
65
|
+
so the banner and voidcraft.world can never disagree."""
|
|
66
|
+
_, status = plugin.handle_get("/status", {})
|
|
67
|
+
if not status.get("reachable"):
|
|
68
|
+
return ("none found — start Ollama or LM Studio (or llama-server), "
|
|
69
|
+
"or set VOIDCRAFT_LOCAL_LLM_URL; the bridge looks again on every request")
|
|
70
|
+
picked = status.get("picked_model") or "no chat model installed yet"
|
|
71
|
+
return f"{status.get('runtime_label')} at {status.get('runtime_url')} → {picked}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fetch_snapshot(port: int, timeout: float = 2.0) -> dict | None:
|
|
75
|
+
"""GET /snapshot from a bridge on this machine, or None if nothing answers."""
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(f"http://{LOOPBACK}:{port}/snapshot", timeout=timeout) as response:
|
|
78
|
+
payload = json.loads(response.read())
|
|
79
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
80
|
+
return None
|
|
81
|
+
return payload if isinstance(payload, dict) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def status(port: int) -> int:
|
|
85
|
+
"""Print what a running bridge reports. Exit 1 when none answers."""
|
|
86
|
+
snapshot = fetch_snapshot(port)
|
|
87
|
+
if snapshot is None:
|
|
88
|
+
_log(f"{NAME}: nothing answering on {LOOPBACK}:{port}")
|
|
89
|
+
return 1
|
|
90
|
+
bridge = snapshot.get("bridge") if isinstance(snapshot.get("bridge"), dict) else {}
|
|
91
|
+
plugins = snapshot.get("plugins") if isinstance(snapshot.get("plugins"), dict) else {}
|
|
92
|
+
_log(f"{bridge.get('name', 'a bridge')} {bridge.get('version', '?')} — ONLINE on {LOOPBACK}:{port}")
|
|
93
|
+
_log(f" plugins: {', '.join(sorted(plugins)) or '(none)'}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: list[str] | None = None) -> int:
|
|
98
|
+
parser = argparse.ArgumentParser(prog=NAME, description="Let voidcraft.world reach your machine.")
|
|
99
|
+
parser.add_argument("--version", action="version", version=f"{NAME} {__version__}")
|
|
100
|
+
parser.add_argument("--port", type=int, default=_default_port(),
|
|
101
|
+
help=f"loopback port (default {DEFAULT_PORT}, or ${PORT_ENV})")
|
|
102
|
+
commands = parser.add_subparsers(dest="command")
|
|
103
|
+
commands.add_parser("status", help="report on a bridge running here")
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
if args.command == "status":
|
|
106
|
+
return status(args.port)
|
|
107
|
+
return serve(args.port)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Who may talk to the bridge: the Host (DNS-rebinding) and Origin guards.
|
|
2
|
+
|
|
3
|
+
Every request a browser sends to a loopback server passes through these, and
|
|
4
|
+
they are the whole reason a page on voidcraft.world may reach this machine while
|
|
5
|
+
a page anywhere else may not. Pure functions over header values, so the tests
|
|
6
|
+
need no socket.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
# The single Origin allowlist for EVERY route: no Origin (curl, CLI, native
|
|
15
|
+
# apps), a deployed VoidCraft origin, or ANY loopback origin — the dev SPA roams
|
|
16
|
+
# ports (vite falls back to 5174+ when 5173 is taken; mkcert flips the scheme),
|
|
17
|
+
# and a loopback page adds nothing an attacker doesn't already have.
|
|
18
|
+
#
|
|
19
|
+
# One guard, every route — read routes included. A host's read routes can leak
|
|
20
|
+
# real content (a terminal pane's visible rows, a process's cwd), and wildcard
|
|
21
|
+
# CORS on those means ANY page the user has open can read them cross-origin. The
|
|
22
|
+
# browser's Private Network Access preflight is no backstop: it is answered
|
|
23
|
+
# permissively for allowlisted origins, and Firefox/Safari don't implement it.
|
|
24
|
+
ALLOWED_BROWSER_ORIGINS = frozenset({
|
|
25
|
+
"https://voidcraft.world",
|
|
26
|
+
"https://www.voidcraft.world",
|
|
27
|
+
"https://dev.voidcraft.world",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
31
|
+
|
|
32
|
+
# Extra Host values beyond loopback (`tailscale serve` remote access). The
|
|
33
|
+
# second name is the one this bridge was born under, still honoured so an
|
|
34
|
+
# existing setup keeps working.
|
|
35
|
+
ALLOWED_HOSTS_ENV = "VOIDCRAFT_BRIDGE_ALLOWED_HOSTS"
|
|
36
|
+
LEGACY_ALLOWED_HOSTS_ENV = "TERMINAL_BRIDGE_ALLOWED_HOSTS"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_loopback_origin(origin: str) -> bool:
|
|
40
|
+
"""True for http(s)://localhost|127.0.0.1|[::1] on any port; malformed → False."""
|
|
41
|
+
try:
|
|
42
|
+
parts = urlsplit(origin)
|
|
43
|
+
return parts.scheme in ("http", "https") and parts.hostname in LOOPBACK_HOSTS
|
|
44
|
+
except ValueError:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def origin_allowed(origin: str | None) -> bool:
|
|
49
|
+
"""Whether a request carrying this Origin may be served at all.
|
|
50
|
+
|
|
51
|
+
`None` passes so that non-browser clients work: curl, CLI subcommands and
|
|
52
|
+
native apps send no Origin.
|
|
53
|
+
|
|
54
|
+
An absent Origin is NOT by itself proof of a non-browser client — see
|
|
55
|
+
`is_no_cors_browser_request`, which expensive routes use to tell the two
|
|
56
|
+
apart. This function is deliberately left permissive because routes that
|
|
57
|
+
are merely *readable* also serve legitimate no-Origin browser requests (a
|
|
58
|
+
plugin page loaded in an iframe, a hand-typed debug URL).
|
|
59
|
+
"""
|
|
60
|
+
return origin is None or origin in ALLOWED_BROWSER_ORIGINS or is_loopback_origin(origin)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_no_cors_browser_request(origin: str | None, sec_fetch_site: str | None) -> bool:
|
|
64
|
+
"""True for a browser request that omitted Origin because it is `no-cors`.
|
|
65
|
+
|
|
66
|
+
`<img>`, `<script src>`, `<iframe src>` and `fetch(url, {mode:'no-cors'})`
|
|
67
|
+
all issue REAL cross-origin GETs while sending no Origin header — so the
|
|
68
|
+
"absent Origin means a native client" reading in `origin_allowed` does not
|
|
69
|
+
hold on its own. What does hold is that every browser making such a request
|
|
70
|
+
sends `Sec-Fetch-Site` (Chrome 76+, Firefox 90+, Safari 16.4+), and no
|
|
71
|
+
curl/URLSession/CLI caller sends it at all.
|
|
72
|
+
|
|
73
|
+
For routes where the request is EXPENSIVE rather than merely readable: the
|
|
74
|
+
attacker cannot read a no-cors response, so the risk there is not disclosure
|
|
75
|
+
but the work performed (a held thread, a subprocess per call).
|
|
76
|
+
"""
|
|
77
|
+
return origin is None and sec_fetch_site is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def host_header_hostname(host_header: str) -> str | None:
|
|
81
|
+
"""The hostname from a Host header, port and IPv6 brackets stripped."""
|
|
82
|
+
try:
|
|
83
|
+
return urlsplit(f"//{host_header}").hostname
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def allowed_remote_hosts() -> frozenset[str]:
|
|
89
|
+
"""Extra Host values accepted beyond loopback, from the environment.
|
|
90
|
+
|
|
91
|
+
Comma-separated in `VOIDCRAFT_BRIDGE_ALLOWED_HOSTS` (or the legacy
|
|
92
|
+
`TERMINAL_BRIDGE_ALLOWED_HOSTS`; both are read). A full URL is accepted and
|
|
93
|
+
reduced to its hostname, so `https://mymac.tailXXXX.ts.net` works.
|
|
94
|
+
"""
|
|
95
|
+
raw = ",".join(os.environ.get(name, "") for name in (ALLOWED_HOSTS_ENV, LEGACY_ALLOWED_HOSTS_ENV))
|
|
96
|
+
hosts: set[str] = set()
|
|
97
|
+
for token in raw.split(","):
|
|
98
|
+
token = token.strip()
|
|
99
|
+
if not token:
|
|
100
|
+
continue
|
|
101
|
+
if "//" in token:
|
|
102
|
+
token = token.split("//", 1)[1]
|
|
103
|
+
parsed = host_header_hostname(token)
|
|
104
|
+
if parsed:
|
|
105
|
+
hosts.add(parsed)
|
|
106
|
+
return frozenset(hosts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def host_allowed(host_header: str | None) -> bool:
|
|
110
|
+
"""Whether the Host this request was addressed to may be served.
|
|
111
|
+
|
|
112
|
+
THE DNS-REBINDING GUARD, and the only defence against it — a loopback bind
|
|
113
|
+
is not one. An attacker page on `rebind.evil.com` (1s TTL, re-resolved to
|
|
114
|
+
127.0.0.1) makes the browser open a genuine same-origin connection to this
|
|
115
|
+
server, so every Origin/CORS check passes by construction: the attacker's
|
|
116
|
+
own hostname IS the origin. Only the Host tells us the request was
|
|
117
|
+
addressed to a name we do not answer to.
|
|
118
|
+
|
|
119
|
+
Absent Host passes (HTTP/1.0 clients omit it; a browser never does).
|
|
120
|
+
|
|
121
|
+
Non-loopback names must be listed explicitly in the env var. **A `*.ts.net`
|
|
122
|
+
suffix rule would be wrong here**: Tailscale *funnel* publishes
|
|
123
|
+
`<machine>.<tailnet>.ts.net` to the public internet, so an attacker with a
|
|
124
|
+
funneled node owns a perfectly real `.ts.net` name. Pin the machine.
|
|
125
|
+
"""
|
|
126
|
+
if host_header is None:
|
|
127
|
+
return True
|
|
128
|
+
hostname = host_header_hostname(host_header)
|
|
129
|
+
if hostname is None:
|
|
130
|
+
return False
|
|
131
|
+
return hostname in LOOPBACK_HOSTS or hostname in allowed_remote_hosts()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Agent runtimes: a model reached some other way than a local server.
|
|
2
|
+
|
|
3
|
+
A `/chat` body with an `agent: {runtime, …}` field goes to the agent runtime of
|
|
4
|
+
that name instead of the local model, on its own queue and worker. The plugin
|
|
5
|
+
ships with NONE; a host that embeds the bridge registers its own by passing
|
|
6
|
+
`agent_runtimes=[…]` to `LocalLlmPlugin` (or `builtin_plugins`). A body naming a
|
|
7
|
+
runtime that is not registered is a 400.
|
|
8
|
+
|
|
9
|
+
An agent runtime is duck-typed:
|
|
10
|
+
|
|
11
|
+
name: str # the `agent.runtime` value it answers to
|
|
12
|
+
default_model: str # the model a body that names none gets
|
|
13
|
+
model_error(model) -> str | None # why a model is not one it takes
|
|
14
|
+
validate_agent(raw: dict) -> (dict | None, str | None)
|
|
15
|
+
# its own `agent` fields, cleaned, or why not
|
|
16
|
+
list_agents() -> list[dict] # rows for `GET /status?agents=1`
|
|
17
|
+
run(request: dict) -> dict # the chat result, or raise AgentRefused
|
|
18
|
+
|
|
19
|
+
The plugin checks the shared contract before `validate_agent` runs: an agent
|
|
20
|
+
takes exactly one system and one user message, and no tools.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentRefused(Exception):
|
|
26
|
+
"""The call must not run, or failed: `code` becomes the job's error code."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, code: str, message: str) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.code = code
|
|
31
|
+
self.message = message
|