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.
@@ -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,4 @@
1
+ """Local intelligence: a job queue in front of a model server on this machine.
2
+
3
+ Built into the bridge (`builtins.py`). Stdlib-only, like the rest of the package.
4
+ """
@@ -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