frutebox-node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/engine_guard.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""The node's ENGINE GUARD — the single chokepoint between an inbound job and the
|
|
2
|
+
model engine running on somebody's real machine.
|
|
3
|
+
|
|
4
|
+
Why this exists
|
|
5
|
+
---------------
|
|
6
|
+
A provider node is an ordinary laptop, desktop or server owned by a volunteer or a
|
|
7
|
+
customer. It accepts work from a gateway on behalf of *any* signed-up user. The job
|
|
8
|
+
frame is therefore fully attacker-controlled data: model name, message list, tool
|
|
9
|
+
schemas, generation flags. Before this module, that data was copied more or less
|
|
10
|
+
verbatim into an HTTP request against a local engine daemon (Ollama / llama.cpp) that
|
|
11
|
+
has **no authentication at all** and a full model-management API.
|
|
12
|
+
|
|
13
|
+
Everything a job can influence now passes through here first.
|
|
14
|
+
|
|
15
|
+
WHAT IS CONTAINED (enforced by this module + its callers)
|
|
16
|
+
---------------------------------------------------------
|
|
17
|
+
* **Engine surface.** Only the chat/generate/embed endpoints are reachable. The
|
|
18
|
+
model-management endpoints (``/api/pull``, ``/api/create``, ``/api/delete``,
|
|
19
|
+
``/api/copy``, ``/api/push``, ``/api/blobs``) and llama.cpp's ``/slots``
|
|
20
|
+
(prompt-cache save/restore to disk) are refused *by path* — no job-handling code
|
|
21
|
+
can construct a URL for them, and a future caller that tries gets an exception
|
|
22
|
+
rather than a silent request.
|
|
23
|
+
* **Model choice.** A job may only ask for a model the node explicitly advertises.
|
|
24
|
+
Anything else is rejected with an error the gateway can re-plan on, instead of
|
|
25
|
+
being handed to an engine that would go and *download* it.
|
|
26
|
+
* **Message shape.** Only ``role``/``content``/``name``/``tool_call_id`` (plus
|
|
27
|
+
sanitised assistant ``tool_calls``) survive. Unknown fields are dropped — this is
|
|
28
|
+
what stops Ollama-specific field injection such as ``images``, and any future
|
|
29
|
+
field the engine learns to honour. Non-text content parts are flattened to text.
|
|
30
|
+
* **Prompt size.** Bounded well below the 8 MB WebSocket frame limit — and the bound
|
|
31
|
+
counts *everything that leaves for the engine*, not just ``content``:
|
|
32
|
+
``tool_calls[].function.arguments`` (itself truncated), ``name`` and
|
|
33
|
+
``tool_call_id`` all count, because each of them was a way past the cap.
|
|
34
|
+
* **Output modality.** A node only generates what it advertises
|
|
35
|
+
(``output_modalities``). The job frame names a modality; ``provider.py`` refuses one
|
|
36
|
+
this machine never offered, instead of loading SDXL on a text-only laptop.
|
|
37
|
+
* **Tool schemas.** Bounded in count, nesting depth and serialised size; function
|
|
38
|
+
names must match a strict charset; every other key is dropped before the schema
|
|
39
|
+
reaches a chat template (llama.cpp runs with ``--jinja``).
|
|
40
|
+
* **Tool EXECUTION.** Never happens on an untrusted node — see
|
|
41
|
+
``assert_tool_execution_allowed``. Off by default even on a trusted one.
|
|
42
|
+
* **Generation budget.** Explicit ``num_ctx``/``num_predict`` (Ollama sent none at
|
|
43
|
+
all before, so generation ran until the context filled) and a hard byte ceiling on
|
|
44
|
+
accumulated output that applies whatever the engine does with those hints.
|
|
45
|
+
* **Engine location.** The engine must be on loopback. A misconfigured or hostile
|
|
46
|
+
``OLLAMA_HOST``/``STEVAI_LLAMACPP_HOST`` cannot turn the node into a proxy that
|
|
47
|
+
posts user prompts to a remote host.
|
|
48
|
+
* **Environment.** Helpers here build a MINIMAL environment for any child process
|
|
49
|
+
the node starts, so cloud credentials sitting in the owner's shell
|
|
50
|
+
(``AWS_*``, ``GOOGLE_APPLICATION_CREDENTIALS``, ``GITHUB_TOKEN``, …) are not
|
|
51
|
+
inherited by the engine or by anything parsing untrusted bytes.
|
|
52
|
+
* **Node-private directories.** Model store and scratch space live under the node's
|
|
53
|
+
own directory (``$STEVAI_WORKSPACE`` when the supervisor provides one — this is
|
|
54
|
+
its first real consumer), not the global temp dir and not the owner's personal
|
|
55
|
+
``~/.ollama``.
|
|
56
|
+
|
|
57
|
+
WHAT IS **NOT** CONTAINED (read this before claiming "sandbox")
|
|
58
|
+
---------------------------------------------------------------
|
|
59
|
+
This is defence in depth at the *process* level. It is NOT a VM and NOT an OS
|
|
60
|
+
sandbox. Specifically, still true after this module:
|
|
61
|
+
|
|
62
|
+
* The node process and the engine daemon run **unconfined as the logged-in user**.
|
|
63
|
+
No namespaces, no seccomp, no chroot, no cgroups, no rlimits. A remote-code-
|
|
64
|
+
execution bug in the engine, in torch, or in a media codec gives the attacker the
|
|
65
|
+
user's account — this module only narrows the *inputs* that reach those parsers.
|
|
66
|
+
* We do not start or supervise the engine daemon on most installs; a shell script
|
|
67
|
+
or Electron does. If that daemon was started by hand with the owner's environment
|
|
68
|
+
and their personal model store, the posture helpers here were never applied.
|
|
69
|
+
``minimal_env()`` only protects children **we** spawn.
|
|
70
|
+
* Nothing here verifies the engine BINARY. An unverified download is still an
|
|
71
|
+
unverified download; guarding its inputs does not make it trustworthy.
|
|
72
|
+
* Memory, CPU share and disk are not capped by the OS. Concurrency and wall-clock
|
|
73
|
+
limits (``provider.py``) bound how much work a job can start, not how much RAM a
|
|
74
|
+
single model load takes.
|
|
75
|
+
* A node that pulls a model at install time still egresses to the model registry.
|
|
76
|
+
* On the desktop app the node still runs inside the Electron main process, so a
|
|
77
|
+
job-handling bug remains an app-level compromise, not just a job failure.
|
|
78
|
+
|
|
79
|
+
Real containment for sensitive work is a *placement* decision (operator-run sealed
|
|
80
|
+
Linux nodes), not something a consumer machine can honestly claim.
|
|
81
|
+
"""
|
|
82
|
+
from __future__ import annotations
|
|
83
|
+
|
|
84
|
+
import ipaddress
|
|
85
|
+
import json
|
|
86
|
+
import os
|
|
87
|
+
import re
|
|
88
|
+
import stat
|
|
89
|
+
from contextlib import asynccontextmanager
|
|
90
|
+
from pathlib import Path
|
|
91
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
92
|
+
from urllib.parse import urlsplit
|
|
93
|
+
|
|
94
|
+
import httpx
|
|
95
|
+
|
|
96
|
+
from .config import CONFIG
|
|
97
|
+
from .envvars import env as envvar
|
|
98
|
+
from .envvars import config_dir
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EngineGuardError(RuntimeError):
|
|
102
|
+
"""A job asked the engine for something this node will not do.
|
|
103
|
+
|
|
104
|
+
Raised (never swallowed) so the node reports an error frame and the gateway
|
|
105
|
+
re-plans, instead of the request quietly happening anyway.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --------------------------------------------------------------------------
|
|
110
|
+
# 1. Engine endpoint allowlist
|
|
111
|
+
# --------------------------------------------------------------------------
|
|
112
|
+
# Inference endpoints. This is the whole reachable surface for job-handling code.
|
|
113
|
+
CHAT_OLLAMA = "/api/chat"
|
|
114
|
+
GENERATE_OLLAMA = "/api/generate"
|
|
115
|
+
EMBED_OLLAMA = "/api/embed"
|
|
116
|
+
CHAT_OPENAI = "/v1/chat/completions"
|
|
117
|
+
COMPLETIONS_OPENAI = "/v1/completions"
|
|
118
|
+
EMBEDDINGS_OPENAI = "/v1/embeddings"
|
|
119
|
+
|
|
120
|
+
ALLOWED_ENDPOINTS = frozenset({
|
|
121
|
+
CHAT_OLLAMA, GENERATE_OLLAMA, EMBED_OLLAMA,
|
|
122
|
+
"/api/embeddings", # Ollama's older embedding path
|
|
123
|
+
CHAT_OPENAI, COMPLETIONS_OPENAI, EMBEDDINGS_OPENAI,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
# Model-management and engine-admin endpoints. Listed EXPLICITLY (rather than relying
|
|
127
|
+
# on "not in the allowlist") so the ban is testable and so widening ALLOWED_ENDPOINTS
|
|
128
|
+
# by accident cannot re-open them. `/api/pull` is the dangerous one: an engine with no
|
|
129
|
+
# auth will happily download and install any model a caller names.
|
|
130
|
+
FORBIDDEN_ENDPOINTS = frozenset({
|
|
131
|
+
"/api/pull", "/api/push", "/api/create", "/api/delete", "/api/copy", "/api/blobs",
|
|
132
|
+
"/api/show", "/api/tags", "/api/ps", "/api/version",
|
|
133
|
+
"/v1/models",
|
|
134
|
+
# llama.cpp server admin surface. /slots can persist and restore prompt caches to
|
|
135
|
+
# disk paths the caller chooses; /props leaks the host's serving config.
|
|
136
|
+
"/slots", "/props", "/completion", "/infill", "/tokenize", "/detokenize",
|
|
137
|
+
"/lora-adapters", "/metrics", "/health",
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
_TRUSTED_LABELS = ("owned", "trusted")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _normalise_base(base: str) -> str:
|
|
144
|
+
"""Scheme + host + port of the engine, with any path/query the caller smuggled in
|
|
145
|
+
thrown away, and the host required to be loopback."""
|
|
146
|
+
raw = (base or "").strip()
|
|
147
|
+
if not raw:
|
|
148
|
+
raise EngineGuardError("no engine host configured")
|
|
149
|
+
if "://" not in raw:
|
|
150
|
+
raw = f"http://{raw}" # Ollama's "127.0.0.1:11434" convention
|
|
151
|
+
parts = urlsplit(raw)
|
|
152
|
+
if parts.scheme not in ("http", "https"):
|
|
153
|
+
raise EngineGuardError(f"engine host must be http(s), got {parts.scheme!r}")
|
|
154
|
+
host = connect_host(parts.hostname or "")
|
|
155
|
+
if not host:
|
|
156
|
+
raise EngineGuardError("engine host has no hostname")
|
|
157
|
+
if not CONFIG.node_engine_allow_remote and not _is_loopback(host):
|
|
158
|
+
raise EngineGuardError(
|
|
159
|
+
f"engine host {host!r} is not loopback — a node must not send prompts off-box. "
|
|
160
|
+
"Set STEVAI_NODE_ENGINE_ALLOW_REMOTE=1 only if the operator runs the engine "
|
|
161
|
+
"on a private link they control.")
|
|
162
|
+
port = f":{parts.port}" if parts.port else ""
|
|
163
|
+
# `parts.hostname` strips the brackets off an IPv6 literal, so they have to go back
|
|
164
|
+
# on — "http://::1:8080/api/chat" is not a parseable URL and every job would fail
|
|
165
|
+
# transport-side on a node whose engine is addressed as [::1].
|
|
166
|
+
if ":" in host:
|
|
167
|
+
host = f"[{host}]"
|
|
168
|
+
return f"{parts.scheme}://{host}{port}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
#: A BIND address meaning "every interface". As a CONNECT address it is not a destination —
|
|
172
|
+
#: it means "this host", i.e. loopback. Containers routinely set OLLAMA_HOST=0.0.0.0 so the
|
|
173
|
+
#: engine listens broadly, and the node then read that same value back as the address to dial.
|
|
174
|
+
#: Verified live 2026-08-21: the warm node registered, was placed, and every job died with
|
|
175
|
+
#: "engine host '0.0.0.0' is not loopback" — the guard was right, the address was wrong.
|
|
176
|
+
_ANY_ADDRESS = {"0.0.0.0", "::", "[::]"}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def connect_host(host: str) -> str:
|
|
180
|
+
"""The address to DIAL for an engine configured with `host`.
|
|
181
|
+
|
|
182
|
+
A wildcard bind address is rewritten to loopback. This is a translation, not a
|
|
183
|
+
relaxation: 0.0.0.0 was never a reachable destination, and the alternative — telling
|
|
184
|
+
operators to set a second variable to the same engine on the same box — is a footgun that
|
|
185
|
+
ends with someone setting ALLOW_REMOTE to silence it.
|
|
186
|
+
"""
|
|
187
|
+
h = (host or "").strip()
|
|
188
|
+
return "127.0.0.1" if h in _ANY_ADDRESS else h
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_loopback(host: str) -> bool:
|
|
192
|
+
if host.lower() in ("localhost", "localhost.localdomain"):
|
|
193
|
+
return True
|
|
194
|
+
try:
|
|
195
|
+
return ipaddress.ip_address(host.strip("[]")).is_loopback
|
|
196
|
+
except ValueError:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def engine_url(base: str, path: str) -> str:
|
|
201
|
+
"""Build the ONE URL a caller is allowed to hit, or raise.
|
|
202
|
+
|
|
203
|
+
Rejects traversal, absolute URLs, protocol-relative paths, query strings and any
|
|
204
|
+
endpoint outside ``ALLOWED_ENDPOINTS``.
|
|
205
|
+
"""
|
|
206
|
+
p = (path or "").strip()
|
|
207
|
+
if not p.startswith("/") or p.startswith("//") or "://" in p or ".." in p:
|
|
208
|
+
raise EngineGuardError(f"illegal engine path {path!r}")
|
|
209
|
+
if "?" in p or "#" in p:
|
|
210
|
+
raise EngineGuardError(f"engine path may not carry a query or fragment: {path!r}")
|
|
211
|
+
canonical = p.rstrip("/") or "/"
|
|
212
|
+
if canonical in FORBIDDEN_ENDPOINTS:
|
|
213
|
+
raise EngineGuardError(
|
|
214
|
+
f"engine endpoint {canonical!r} is model management / admin and is never "
|
|
215
|
+
"reachable from job handling")
|
|
216
|
+
if canonical not in ALLOWED_ENDPOINTS:
|
|
217
|
+
raise EngineGuardError(f"engine endpoint {canonical!r} is not on the node's allowlist")
|
|
218
|
+
return f"{_normalise_base(base)}{canonical}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --------------------------------------------------------------------------
|
|
222
|
+
# 2. Model allowlist — a job may only name a model this node actually advertises
|
|
223
|
+
# --------------------------------------------------------------------------
|
|
224
|
+
_allowed_models: "Optional[set]" = None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def arm_models(models: Iterable[str]) -> None:
|
|
228
|
+
"""Pin the models this node will ask its engine for.
|
|
229
|
+
|
|
230
|
+
Called by the provider agent at startup with what it advertises to the directory.
|
|
231
|
+
Both the catalog id and the concrete engine tag are accepted, because the gateway
|
|
232
|
+
may legitimately send either. An empty/blank list leaves the guard DISARMED
|
|
233
|
+
(permissive) — that is the gateway/test context, where there is no node to protect.
|
|
234
|
+
|
|
235
|
+
That permissive default is load-bearing for the gateway and a foot-gun on a node:
|
|
236
|
+
a set-but-EMPTY ``$STEVAI_OLLAMA_MODEL`` used to reach here as ``[""]`` and turn
|
|
237
|
+
``check_model`` into a process-wide pass-through. ``provider.py`` therefore checks
|
|
238
|
+
``models_armed()`` after calling this and REFUSES TO START if it is False. Any new
|
|
239
|
+
caller that guards a real engine must do the same — do not assume arming worked.
|
|
240
|
+
"""
|
|
241
|
+
global _allowed_models
|
|
242
|
+
from .manifest import OLLAMA_TAGS
|
|
243
|
+
|
|
244
|
+
vals: set = set()
|
|
245
|
+
for m in models or []:
|
|
246
|
+
name = str(m or "").strip()
|
|
247
|
+
if not name:
|
|
248
|
+
continue
|
|
249
|
+
vals.add(name)
|
|
250
|
+
vals.add(OLLAMA_TAGS.get(name, name))
|
|
251
|
+
_allowed_models = vals or None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def disarm_models() -> None:
|
|
255
|
+
"""Drop the allowlist (tests, and the gateway-side import of `inference`)."""
|
|
256
|
+
global _allowed_models
|
|
257
|
+
_allowed_models = None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def models_armed() -> bool:
|
|
261
|
+
return _allowed_models is not None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def allowed_models() -> List[str]:
|
|
265
|
+
return sorted(_allowed_models or ())
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def check_model(requested: str, resolved: str) -> str:
|
|
269
|
+
"""Return `resolved` if this node serves it, else raise.
|
|
270
|
+
|
|
271
|
+
Rejecting is the point: an unguarded engine treats "model I don't have" as
|
|
272
|
+
"model I should go and fetch", which is remote-controlled disk + bandwidth use
|
|
273
|
+
on somebody else's machine.
|
|
274
|
+
"""
|
|
275
|
+
if _allowed_models is None:
|
|
276
|
+
return resolved
|
|
277
|
+
if resolved in _allowed_models or (requested or "") in _allowed_models:
|
|
278
|
+
return resolved
|
|
279
|
+
raise EngineGuardError(
|
|
280
|
+
f"model {requested!r} is not served by this node "
|
|
281
|
+
f"(serves: {', '.join(allowed_models()) or 'nothing'})")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# --------------------------------------------------------------------------
|
|
285
|
+
# 3. Message / tool sanitisation
|
|
286
|
+
# --------------------------------------------------------------------------
|
|
287
|
+
# "function" is the deprecated OpenAI spelling of "tool"; accepted so an older
|
|
288
|
+
# OpenAI-compatible client is not broken by the guard.
|
|
289
|
+
_ROLES = ("system", "developer", "user", "assistant", "tool", "function")
|
|
290
|
+
_TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$")
|
|
291
|
+
_MAX_TOOL_DEPTH = 8
|
|
292
|
+
# Ceiling on ONE recorded tool call's `arguments`. Real arguments are a small JSON
|
|
293
|
+
# object; this only bites on abuse. Without it, `arguments` was the one field in a
|
|
294
|
+
# message that was copied through at *any* length — see the accounting in
|
|
295
|
+
# `sanitize_messages`.
|
|
296
|
+
MAX_TOOL_ARG_BYTES = 8192
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _content_to_text(content: Any) -> str:
|
|
300
|
+
"""Text of a message. A list of parts is flattened to its text parts ONLY — the
|
|
301
|
+
non-text parts (image_url, input_audio, arbitrary dicts) are deliberately dropped
|
|
302
|
+
rather than forwarded, so a caller cannot smuggle bytes into the engine through a
|
|
303
|
+
content part."""
|
|
304
|
+
if content is None:
|
|
305
|
+
return ""
|
|
306
|
+
if isinstance(content, str):
|
|
307
|
+
return content
|
|
308
|
+
if isinstance(content, list):
|
|
309
|
+
parts = []
|
|
310
|
+
for p in content:
|
|
311
|
+
if isinstance(p, str):
|
|
312
|
+
parts.append(p)
|
|
313
|
+
elif isinstance(p, dict) and isinstance(p.get("text"), str):
|
|
314
|
+
parts.append(p["text"])
|
|
315
|
+
return " ".join(x for x in parts if x)
|
|
316
|
+
raise EngineGuardError("message content must be text (or text parts)")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _sanitize_tool_calls(calls: Any) -> List[Dict]:
|
|
320
|
+
"""Assistant tool-call history, reduced to id/type/function{name,arguments}."""
|
|
321
|
+
out: List[Dict] = []
|
|
322
|
+
if not isinstance(calls, list):
|
|
323
|
+
return out
|
|
324
|
+
for c in calls[:CONFIG.node_max_tools]:
|
|
325
|
+
if not isinstance(c, dict):
|
|
326
|
+
continue
|
|
327
|
+
fn = c.get("function")
|
|
328
|
+
if not isinstance(fn, dict):
|
|
329
|
+
continue
|
|
330
|
+
name = str(fn.get("name") or "")
|
|
331
|
+
if not _TOOL_NAME_RE.match(name):
|
|
332
|
+
raise EngineGuardError(f"illegal tool name in history: {name!r}")
|
|
333
|
+
args = fn.get("arguments")
|
|
334
|
+
if isinstance(args, dict):
|
|
335
|
+
encoded = json.dumps(args, default=str).encode("utf-8", "ignore")
|
|
336
|
+
if len(encoded) > MAX_TOOL_ARG_BYTES:
|
|
337
|
+
# Over-cap: degrade the object to truncated text rather than forward
|
|
338
|
+
# megabytes of it. Shape is preserved for the honest (small) case.
|
|
339
|
+
args = encoded[:MAX_TOOL_ARG_BYTES].decode("utf-8", "ignore")
|
|
340
|
+
elif isinstance(args, str):
|
|
341
|
+
raw = args.encode("utf-8", "ignore")
|
|
342
|
+
if len(raw) > MAX_TOOL_ARG_BYTES:
|
|
343
|
+
args = raw[:MAX_TOOL_ARG_BYTES].decode("utf-8", "ignore")
|
|
344
|
+
else:
|
|
345
|
+
args = ""
|
|
346
|
+
clean: Dict = {"type": "function", "function": {"name": name, "arguments": args}}
|
|
347
|
+
if isinstance(c.get("id"), str):
|
|
348
|
+
clean["id"] = c["id"][:128]
|
|
349
|
+
out.append(clean)
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def sanitize_messages(messages: Any) -> List[Dict]:
|
|
354
|
+
"""Strip every field the engine did not need to see, and bound the prompt.
|
|
355
|
+
|
|
356
|
+
This is the field-injection kill: whatever the API client (or a compromised
|
|
357
|
+
gateway) attached to a message, only the four chat fields survive.
|
|
358
|
+
"""
|
|
359
|
+
if not isinstance(messages, list):
|
|
360
|
+
raise EngineGuardError("messages must be a list")
|
|
361
|
+
if len(messages) > CONFIG.node_max_messages:
|
|
362
|
+
raise EngineGuardError(
|
|
363
|
+
f"too many messages ({len(messages)} > {CONFIG.node_max_messages})")
|
|
364
|
+
|
|
365
|
+
out: List[Dict] = []
|
|
366
|
+
total = 0
|
|
367
|
+
for m in messages:
|
|
368
|
+
if not isinstance(m, dict):
|
|
369
|
+
raise EngineGuardError("each message must be an object")
|
|
370
|
+
role = str(m.get("role") or "user")
|
|
371
|
+
if role not in _ROLES:
|
|
372
|
+
raise EngineGuardError(f"unknown message role {role!r}")
|
|
373
|
+
clean: Dict = {"role": role, "content": _content_to_text(m.get("content"))}
|
|
374
|
+
name = m.get("name")
|
|
375
|
+
if isinstance(name, str) and name:
|
|
376
|
+
clean["name"] = name[:128]
|
|
377
|
+
tcid = m.get("tool_call_id")
|
|
378
|
+
if isinstance(tcid, str) and tcid:
|
|
379
|
+
clean["tool_call_id"] = tcid[:128]
|
|
380
|
+
if role == "assistant" and m.get("tool_calls"):
|
|
381
|
+
tcs = _sanitize_tool_calls(m.get("tool_calls"))
|
|
382
|
+
if tcs:
|
|
383
|
+
clean["tool_calls"] = tcs
|
|
384
|
+
# COUNT EVERY BYTE THAT ACTUALLY LEAVES FOR THE ENGINE, not just `content`.
|
|
385
|
+
# Counting content alone left `tool_calls[].function.arguments` (and `name` /
|
|
386
|
+
# `tool_call_id`) entirely outside the cap: one legal 8 MB WebSocket frame
|
|
387
|
+
# carried 7.5 MB of tool-call arguments past a 1 MB documented limit, and
|
|
388
|
+
# unconstrained by the frame the same shape reached gigabytes.
|
|
389
|
+
total += len(clean["content"].encode("utf-8", "ignore")) + 32
|
|
390
|
+
total += len(clean.get("name", "")) + len(clean.get("tool_call_id", ""))
|
|
391
|
+
if clean.get("tool_calls"):
|
|
392
|
+
total += len(json.dumps(clean["tool_calls"],
|
|
393
|
+
default=str).encode("utf-8", "ignore"))
|
|
394
|
+
if total > CONFIG.node_max_prompt_bytes:
|
|
395
|
+
raise EngineGuardError(
|
|
396
|
+
f"prompt exceeds this node's {CONFIG.node_max_prompt_bytes} byte limit")
|
|
397
|
+
out.append(clean)
|
|
398
|
+
return out
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _depth_ok(obj: Any, depth: int = 0) -> bool:
|
|
402
|
+
if depth > _MAX_TOOL_DEPTH:
|
|
403
|
+
return False
|
|
404
|
+
if isinstance(obj, dict):
|
|
405
|
+
return all(_depth_ok(v, depth + 1) for v in obj.values())
|
|
406
|
+
if isinstance(obj, list):
|
|
407
|
+
return all(_depth_ok(v, depth + 1) for v in obj)
|
|
408
|
+
return True
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def sanitize_tools(tools: Any, *, tool_choice: Any = None) -> "Optional[List[Dict]]":
|
|
412
|
+
"""Bound and scrub tool DEFINITIONS before they reach a chat template.
|
|
413
|
+
|
|
414
|
+
Returns None when there is nothing to send — including when the owner has turned
|
|
415
|
+
definition passthrough off (``STEVAI_NODE_ALLOW_TOOL_DEFS=0``) and when
|
|
416
|
+
``tool_choice`` is "none", which is enforced here by withholding the tools
|
|
417
|
+
entirely rather than trusting the engine to honour a flag.
|
|
418
|
+
|
|
419
|
+
NOTE: forwarding a definition is not executing anything. Nothing on the node runs
|
|
420
|
+
a tool — see ``assert_tool_execution_allowed``.
|
|
421
|
+
"""
|
|
422
|
+
if tool_choice == "none" or not tools:
|
|
423
|
+
return None
|
|
424
|
+
if not CONFIG.node_allow_tool_defs:
|
|
425
|
+
return None
|
|
426
|
+
if not isinstance(tools, list):
|
|
427
|
+
raise EngineGuardError("tools must be a list")
|
|
428
|
+
if len(tools) > CONFIG.node_max_tools:
|
|
429
|
+
raise EngineGuardError(f"too many tools ({len(tools)} > {CONFIG.node_max_tools})")
|
|
430
|
+
|
|
431
|
+
out: List[Dict] = []
|
|
432
|
+
for t in tools:
|
|
433
|
+
if not isinstance(t, dict):
|
|
434
|
+
raise EngineGuardError("each tool must be an object")
|
|
435
|
+
if str(t.get("type") or "function") != "function":
|
|
436
|
+
raise EngineGuardError(f"unsupported tool type {t.get('type')!r}")
|
|
437
|
+
fn = t.get("function")
|
|
438
|
+
if not isinstance(fn, dict):
|
|
439
|
+
raise EngineGuardError("tool.function must be an object")
|
|
440
|
+
name = str(fn.get("name") or "")
|
|
441
|
+
if not _TOOL_NAME_RE.match(name):
|
|
442
|
+
raise EngineGuardError(f"illegal tool name {name!r}")
|
|
443
|
+
clean_fn: Dict = {"name": name}
|
|
444
|
+
desc = fn.get("description")
|
|
445
|
+
if isinstance(desc, str) and desc:
|
|
446
|
+
clean_fn["description"] = desc[:1024]
|
|
447
|
+
params = fn.get("parameters")
|
|
448
|
+
if params is not None:
|
|
449
|
+
if not isinstance(params, dict):
|
|
450
|
+
raise EngineGuardError(f"tool {name!r}: parameters must be an object")
|
|
451
|
+
if not _depth_ok(params):
|
|
452
|
+
raise EngineGuardError(f"tool {name!r}: parameter schema nests too deeply")
|
|
453
|
+
clean_fn["parameters"] = params
|
|
454
|
+
out.append({"type": "function", "function": clean_fn})
|
|
455
|
+
|
|
456
|
+
size = len(json.dumps(out, default=str).encode("utf-8", "ignore"))
|
|
457
|
+
if size > CONFIG.node_max_tool_bytes:
|
|
458
|
+
raise EngineGuardError(
|
|
459
|
+
f"tool schemas exceed this node's {CONFIG.node_max_tool_bytes} byte limit")
|
|
460
|
+
return out or None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def sanitize_tool_choice(tool_choice: Any) -> Any:
|
|
464
|
+
"""Normalise tool_choice to the small set of shapes an engine may be told about."""
|
|
465
|
+
if tool_choice is None:
|
|
466
|
+
return None
|
|
467
|
+
if isinstance(tool_choice, str):
|
|
468
|
+
if tool_choice in ("none", "auto", "required"):
|
|
469
|
+
return tool_choice
|
|
470
|
+
raise EngineGuardError(f"unknown tool_choice {tool_choice!r}")
|
|
471
|
+
if isinstance(tool_choice, dict):
|
|
472
|
+
fn = tool_choice.get("function")
|
|
473
|
+
name = str((fn or {}).get("name") or "") if isinstance(fn, dict) else ""
|
|
474
|
+
if not _TOOL_NAME_RE.match(name):
|
|
475
|
+
raise EngineGuardError("tool_choice must name a valid function")
|
|
476
|
+
return {"type": "function", "function": {"name": name}}
|
|
477
|
+
raise EngineGuardError("tool_choice must be a string or a function selector")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# --------------------------------------------------------------------------
|
|
481
|
+
# 4. Node-side tool EXECUTION — trust-gated, off by default, absent today
|
|
482
|
+
# --------------------------------------------------------------------------
|
|
483
|
+
def tool_execution_allowed(trust: str) -> bool:
|
|
484
|
+
"""May THIS node run a tool the model asked for, on the host?
|
|
485
|
+
|
|
486
|
+
Two independent conditions, both required:
|
|
487
|
+
* the operator labelled this node ``owned``/``trusted`` (never self-declared —
|
|
488
|
+
the label arrives from the gateway, which got it from directory policy), and
|
|
489
|
+
* the machine's owner opted in (``STEVAI_NODE_ALLOW_TOOL_EXEC=1``).
|
|
490
|
+
|
|
491
|
+
An untrusted consumer node can therefore never be talked into running anything,
|
|
492
|
+
whatever the env says. Today nothing on the node executes tools at all: the model's
|
|
493
|
+
``tool_calls`` are returned to the API client, which owns the executor. This gate
|
|
494
|
+
exists so that seam cannot be crossed by accident later.
|
|
495
|
+
"""
|
|
496
|
+
return bool(CONFIG.node_allow_tool_exec) and (trust or "untrusted") in _TRUSTED_LABELS
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def assert_tool_execution_allowed(trust: str, tool_name: str = "") -> None:
|
|
500
|
+
if not tool_execution_allowed(trust):
|
|
501
|
+
raise EngineGuardError(
|
|
502
|
+
f"this node will not execute host-side tools"
|
|
503
|
+
f"{f' (requested: {tool_name!r})' if tool_name else ''}: "
|
|
504
|
+
f"trust={trust or 'untrusted'}, "
|
|
505
|
+
f"owner opt-in={'on' if CONFIG.node_allow_tool_exec else 'off'}")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --------------------------------------------------------------------------
|
|
509
|
+
# 5. Generation budget
|
|
510
|
+
# --------------------------------------------------------------------------
|
|
511
|
+
def ollama_options() -> Dict[str, int]:
|
|
512
|
+
"""Explicit generation bounds for Ollama. Before this the payload carried NO
|
|
513
|
+
options at all, so a job generated until the context window filled."""
|
|
514
|
+
return {
|
|
515
|
+
"num_ctx": int(CONFIG.node_max_context_tokens),
|
|
516
|
+
"num_predict": int(CONFIG.node_max_output_tokens),
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def sanitize_think(value: object) -> "Optional[bool]":
|
|
521
|
+
"""Normalise the job's thinking hint to a strict bool, or None to leave it to the model.
|
|
522
|
+
|
|
523
|
+
MEASURED, 2026-08-21: a reasoning model spent ~20 seconds of GPU time — real electricity,
|
|
524
|
+
which is what we bill on — producing hundreds of thinking tokens before answering "PONG".
|
|
525
|
+
On turns that need no reasoning that is pure waste, paid for by the user and the operator
|
|
526
|
+
and felt as a slow answer.
|
|
527
|
+
|
|
528
|
+
Ollama's `think` is silently ignored by models that do not reason, so sending it is safe
|
|
529
|
+
across a mixed fleet. It is normalised here rather than passed through because it arrives
|
|
530
|
+
in a JOB, and a job is caller-controlled input: anything but a real bool is discarded.
|
|
531
|
+
"""
|
|
532
|
+
return value if isinstance(value, bool) else None
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def sanitize_think_effort(value: object) -> str:
|
|
536
|
+
"""Normalise the job's effort tier to one of `thinkbudget.TIERS`, or "" for "not stated".
|
|
537
|
+
|
|
538
|
+
AN ALLOW-LIST, like every other field that arrives in a JOB. `sanitize_think` next door takes a
|
|
539
|
+
strict bool for the same reason: a job is caller-controlled input, and a node that accepted an
|
|
540
|
+
arbitrary string here would forward it to the engine as a reasoning level. "" means the node
|
|
541
|
+
applies no cap and asks for no level, which is exactly today's behaviour — so a gateway that has
|
|
542
|
+
not been redeployed is unaffected.
|
|
543
|
+
"""
|
|
544
|
+
from . import thinkbudget
|
|
545
|
+
return value if isinstance(value, str) and value in thinkbudget.TIERS else ""
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
#: Reasoning budgets are stated in TOKENS (that is what a model spends and what we bill), and the
|
|
549
|
+
#: cap is enforced over CHARACTERS, because a delta is text and no tokeniser is available on the
|
|
550
|
+
#: streaming path. Four is the usual English ratio and the conversion is deliberately generous: a
|
|
551
|
+
#: cap that fired early would truncate reasoning a turn had legitimately earned.
|
|
552
|
+
CHARS_PER_TOKEN = 4
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def reasoning_char_budget(effort: str) -> "Optional[int]":
|
|
556
|
+
"""The character cap for this tier, or None for unbounded/not-stated."""
|
|
557
|
+
from . import thinkbudget
|
|
558
|
+
if not effort:
|
|
559
|
+
return None
|
|
560
|
+
toks = thinkbudget.max_reasoning_tokens(effort)
|
|
561
|
+
return None if toks is None else toks * CHARS_PER_TOKEN
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def llamacpp_max_tokens(requested: int = 0) -> int:
|
|
565
|
+
"""Output budget for a llama.cpp turn, clamped to the node's ceiling. Reasoning
|
|
566
|
+
models legitimately need a large budget, which is why this ceiling is separate
|
|
567
|
+
from the Ollama one."""
|
|
568
|
+
ceiling = int(CONFIG.node_max_output_tokens_llamacpp)
|
|
569
|
+
want = int(requested or 0) or ceiling
|
|
570
|
+
return max(1, min(want, ceiling))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
class OutputMeter:
|
|
574
|
+
"""Hard byte ceiling on what one job may produce, independent of the engine.
|
|
575
|
+
|
|
576
|
+
``num_predict``/``max_tokens`` are hints an engine may ignore or mis-apply; this
|
|
577
|
+
is the backstop that actually stops a runaway generation from filling a
|
|
578
|
+
volunteer's RAM and the 8 MB result frame.
|
|
579
|
+
"""
|
|
580
|
+
|
|
581
|
+
def __init__(self, max_bytes: "Optional[int]" = None) -> None:
|
|
582
|
+
self.max_bytes = int(max_bytes or CONFIG.node_max_output_bytes)
|
|
583
|
+
self.total = 0
|
|
584
|
+
|
|
585
|
+
def add(self, text: str) -> str:
|
|
586
|
+
self.total += len((text or "").encode("utf-8", "ignore"))
|
|
587
|
+
if self.total > self.max_bytes:
|
|
588
|
+
raise EngineGuardError(
|
|
589
|
+
f"engine output exceeded this node's {self.max_bytes} byte limit — job cancelled")
|
|
590
|
+
return text
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def check_voice_sample(sample: "Optional[bytes]") -> "Optional[bytes]":
|
|
594
|
+
"""Bound attacker-supplied media before it is written anywhere or handed to a
|
|
595
|
+
C/C++/torch decoder. The gateway also scans it; this is the node's own check,
|
|
596
|
+
because a node must not depend on its gateway having done the right thing."""
|
|
597
|
+
if not sample:
|
|
598
|
+
return None
|
|
599
|
+
cap = int(CONFIG.node_max_voice_sample_bytes)
|
|
600
|
+
if len(sample) > cap:
|
|
601
|
+
raise EngineGuardError(f"voice sample exceeds this node's {cap} byte limit")
|
|
602
|
+
return sample
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# --------------------------------------------------------------------------
|
|
606
|
+
# 6. Least-privilege process posture
|
|
607
|
+
# --------------------------------------------------------------------------
|
|
608
|
+
# Variables a child genuinely needs to run. EVERYTHING else — every cloud token,
|
|
609
|
+
# every API key the owner exported into their shell — is dropped.
|
|
610
|
+
_ENV_KEEP = (
|
|
611
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
|
|
612
|
+
"TMPDIR", "TEMP", "TMP", "PWD",
|
|
613
|
+
# Windows needs these to start a process at all.
|
|
614
|
+
"SystemRoot", "SystemDrive", "windir", "COMSPEC", "PATHEXT", "USERPROFILE",
|
|
615
|
+
"APPDATA", "LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE",
|
|
616
|
+
# TLS trust + native libs + GPU visibility.
|
|
617
|
+
"SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "LD_LIBRARY_PATH",
|
|
618
|
+
"DYLD_LIBRARY_PATH", "CUDA_VISIBLE_DEVICES", "NVIDIA_VISIBLE_DEVICES",
|
|
619
|
+
# Python needs to find itself and the package.
|
|
620
|
+
"PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", "PYTHONIOENCODING",
|
|
621
|
+
)
|
|
622
|
+
# Our own config travels; nothing else with a prefix does.
|
|
623
|
+
_ENV_KEEP_PREFIXES = ("STEVAI_", "OLLAMA_")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def minimal_env(extra: "Optional[Dict[str, str]]" = None,
|
|
627
|
+
*, keep: Iterable[str] = _ENV_KEEP,
|
|
628
|
+
keep_prefixes: Iterable[str] = _ENV_KEEP_PREFIXES) -> Dict[str, str]:
|
|
629
|
+
"""A child's environment, built by allowlist instead of inherited wholesale.
|
|
630
|
+
|
|
631
|
+
``{**os.environ}`` on a developer's or a company server's machine hands the child
|
|
632
|
+
every credential on the box. This does not.
|
|
633
|
+
"""
|
|
634
|
+
env: Dict[str, str] = {}
|
|
635
|
+
prefixes = tuple(keep_prefixes)
|
|
636
|
+
for k, v in os.environ.items():
|
|
637
|
+
if k in keep or (prefixes and k.startswith(prefixes)):
|
|
638
|
+
env[k] = v
|
|
639
|
+
for k, v in (extra or {}).items():
|
|
640
|
+
if v is None:
|
|
641
|
+
env.pop(k, None)
|
|
642
|
+
else:
|
|
643
|
+
env[k] = str(v)
|
|
644
|
+
return env
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def node_home() -> Path:
|
|
648
|
+
"""This node's private directory.
|
|
649
|
+
|
|
650
|
+
``$STEVAI_WORKSPACE`` wins when the supervisor provided one — the supervisor has
|
|
651
|
+
been setting it and, until now, nothing read it, so its "wipe on stop" only ever
|
|
652
|
+
deleted a marker file. Anything we put here is genuinely wiped when sharing stops.
|
|
653
|
+
"""
|
|
654
|
+
ws = (envvar("WORKSPACE") or "").strip()
|
|
655
|
+
root = Path(ws) if ws else config_dir() / "node"
|
|
656
|
+
return root
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _private_dir(path: Path) -> Path:
|
|
660
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
try:
|
|
662
|
+
os.chmod(path, stat.S_IRWXU) # 0700 — owner only (no-op on Windows)
|
|
663
|
+
except OSError:
|
|
664
|
+
pass
|
|
665
|
+
return path
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def models_dir() -> Path:
|
|
669
|
+
"""Where the node keeps ITS models. Separate from ``~/.ollama`` on purpose: network
|
|
670
|
+
jobs must not read, evict or grow the owner's personal model store."""
|
|
671
|
+
override = (envvar("NODE_MODELS_DIR") or "").strip()
|
|
672
|
+
return _private_dir(Path(override) if override else node_home() / "models")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def scratch_dir() -> Path:
|
|
676
|
+
"""Node-private scratch for untrusted bytes (e.g. a voice sample), instead of the
|
|
677
|
+
world-readable global temp directory."""
|
|
678
|
+
return _private_dir(node_home() / "scratch")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def engine_env(*, host: str = "127.0.0.1:11434",
|
|
682
|
+
models: "Optional[str]" = None,
|
|
683
|
+
extra: "Optional[Dict[str, str]]" = None) -> Dict[str, str]:
|
|
684
|
+
"""Environment for an engine daemon WE start: loopback bind, private model store,
|
|
685
|
+
no inherited secrets. (Installs where a shell script or Electron starts the engine
|
|
686
|
+
do not go through here — see the module docstring.)"""
|
|
687
|
+
env = {
|
|
688
|
+
"OLLAMA_HOST": host,
|
|
689
|
+
"OLLAMA_MODELS": models or str(models_dir()),
|
|
690
|
+
"OLLAMA_ORIGINS": "http://127.0.0.1", # no drive-by from a web page
|
|
691
|
+
"OLLAMA_NOHISTORY": "1",
|
|
692
|
+
}
|
|
693
|
+
env.update(extra or {})
|
|
694
|
+
return minimal_env(env)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
# --------------------------------------------------------------------------
|
|
698
|
+
# 7. Transport — the only place job-handling code talks to an engine
|
|
699
|
+
# --------------------------------------------------------------------------
|
|
700
|
+
async def post_json(base: str, path: str, payload: Dict, timeout: float) -> Dict:
|
|
701
|
+
"""One guarded, non-streaming request. Raises on any transport/engine failure so
|
|
702
|
+
the caller re-routes rather than inventing an answer."""
|
|
703
|
+
url = engine_url(base, path)
|
|
704
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
705
|
+
r = await client.post(url, json=payload)
|
|
706
|
+
r.raise_for_status()
|
|
707
|
+
return r.json()
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@asynccontextmanager
|
|
711
|
+
async def stream(base: str, path: str, payload: Dict, timeout: float):
|
|
712
|
+
"""One guarded streaming request. Yields the response object; leaving the block
|
|
713
|
+
closes the stream, which is what actually stops the engine generating."""
|
|
714
|
+
url = engine_url(base, path)
|
|
715
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
716
|
+
async with client.stream("POST", url, json=payload) as r:
|
|
717
|
+
r.raise_for_status()
|
|
718
|
+
yield r
|