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/packaging.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Package the node software so contributors can actually download and run it.
|
|
2
|
+
|
|
3
|
+
The "downloadable software" is the provider node (``frutebox/`` + a tiny installer).
|
|
4
|
+
This module builds a self-contained tarball on demand and produces the one-line
|
|
5
|
+
installer the control plane serves at ``/install.sh``. No build infra, no
|
|
6
|
+
registry — the accountable operator's own directory hands out the software:
|
|
7
|
+
|
|
8
|
+
curl -fsSL https://<directory>/install.sh | bash
|
|
9
|
+
|
|
10
|
+
which downloads the tarball, makes a venv, installs deps, and leaves a
|
|
11
|
+
``run-node.sh`` wrapper. Everything here is local file work; nothing is uploaded.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import io
|
|
17
|
+
import tarfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import signedconfig
|
|
21
|
+
|
|
22
|
+
NODE_VERSION = "0.2.0"
|
|
23
|
+
_PKG_DIR = Path(__file__).resolve().parent # the frutebox/ package
|
|
24
|
+
_PROJECT = _PKG_DIR.parent
|
|
25
|
+
_ARCHIVE_ROOT = "frutebox-node" # top-level dir inside the tar
|
|
26
|
+
|
|
27
|
+
# The installer that ships INSIDE the tarball (venv + deps + engine + model + launcher).
|
|
28
|
+
# It provisions everything a plain laptop/desktop needs to serve real inference: a venv,
|
|
29
|
+
# the Ollama engine, and a CPU-runnable model — mirroring what deploy/*.sh do for the
|
|
30
|
+
# managed GPU nodes, but self-contained and cross-platform (Linux auto-install, macOS
|
|
31
|
+
# points the user at the one-time Ollama download).
|
|
32
|
+
_BUNDLED_INSTALL = """#!/usr/bin/env bash
|
|
33
|
+
set -euo pipefail
|
|
34
|
+
cd "$(dirname "$0")"
|
|
35
|
+
echo "▶ creating virtualenv and installing dependencies …"
|
|
36
|
+
python3 -m venv .venv
|
|
37
|
+
. .venv/bin/activate
|
|
38
|
+
pip install -q --upgrade pip
|
|
39
|
+
pip install -q -r requirements.txt
|
|
40
|
+
|
|
41
|
+
# --- MEDIA EXTRAS, REINSTALLED BECAUSE THE PACKAGE REFRESH DESTROYS THEM ----------------
|
|
42
|
+
#
|
|
43
|
+
# THE BUG, measured 2026-10-14 on frutebox-runpod-2. The media image is built with
|
|
44
|
+
# the image-generation build arg, which pip-installs torch + diffusers into
|
|
45
|
+
# `/opt/frutebox-node/.venv`. Then `deploy/runpod/entrypoint.sh` boots and does:
|
|
46
|
+
#
|
|
47
|
+
# rm -rf frutebox-node && ... && tar -xzf node.tgz ... && bash install.sh
|
|
48
|
+
#
|
|
49
|
+
# `rm -rf` takes the venv with it, and the line above rebuilds a PLAIN venv (no
|
|
50
|
+
# --system-site-packages), so every boot silently undid the entire media build. The node still
|
|
51
|
+
# ADVERTISED image — `output_modalities` is set from GEN_MODALITIES — so the gateway dispatched
|
|
52
|
+
# to it and every job came back:
|
|
53
|
+
#
|
|
54
|
+
# RuntimeError: image generation is not available on this node: No module named 'torch'
|
|
55
|
+
#
|
|
56
|
+
# which the dispatch loop turned into "no node is serving 'image'". Hours were spent looking for
|
|
57
|
+
# missing capacity in front of a node that was up, advertising correctly, and amputated at boot.
|
|
58
|
+
#
|
|
59
|
+
# IT GOES HERE, not in the Dockerfile, for one practical reason: this file ships in the node
|
|
60
|
+
# package served by the directory, so it reaches every pod on the next boot without rebuilding and
|
|
61
|
+
# pushing a ~10 GB image. `install.sh` is also NOT in `_AGENT_FILES`, so changing it does not move
|
|
62
|
+
# the agent digest and cannot cause an attestation refusal.
|
|
63
|
+
#
|
|
64
|
+
# IDEMPOTENT AND NON-FATAL. If torch already imports (a correctly baked image, or a second boot
|
|
65
|
+
# with a warm pip cache) this is a no-op. If the install fails, the node still starts and serves
|
|
66
|
+
# TEXT — a node that refuses to boot because it cannot do images is worse than one that does the
|
|
67
|
+
# half it can, and the gateway's own gate already refuses a modality nothing can serve.
|
|
68
|
+
MEDIA_WANT="${FRUTEBOX_GEN_MODALITIES:-${STEVAI_GEN_MODALITIES:-}}"
|
|
69
|
+
case ",${MEDIA_WANT}," in
|
|
70
|
+
*,image,*|*,audio,*|*,video,*)
|
|
71
|
+
if python -c "import torch" >/dev/null 2>&1; then
|
|
72
|
+
echo "▶ media extras already present"
|
|
73
|
+
else
|
|
74
|
+
echo "▶ installing media extras (torch, diffusers, transformers) for: ${MEDIA_WANT}"
|
|
75
|
+
# THE WHEEL MUST MATCH THE POD'S DRIVER, and the default PyPI wheel did not.
|
|
76
|
+
#
|
|
77
|
+
# MEASURED 2026-10-14: plain `pip install torch` installed a build for a newer CUDA than
|
|
78
|
+
# the node's driver, and torch then said
|
|
79
|
+
# "CUDA initialization: The NVIDIA driver on your system is too old (found version 12080)"
|
|
80
|
+
# so `cuda_getDeviceCount()` returned 0, `imagegen.available()` was False, and the node
|
|
81
|
+
# honestly advertised no image modality. torch imported perfectly — the failure was one
|
|
82
|
+
# layer down, in the GPU it could not see, which reads identically from the outside.
|
|
83
|
+
#
|
|
84
|
+
# cu124 rather than cu128: `deploy/runpod/Dockerfile` allows CUDA 12.4 through 12.8, and a
|
|
85
|
+
# cu124 build runs on any driver at or above 12.4 (CUDA minor-version compatibility), while
|
|
86
|
+
# a wheel pinned to the newest allowed driver fails on every older one. Lowest allowed
|
|
87
|
+
# version is the only choice that works across the whole fleet.
|
|
88
|
+
#
|
|
89
|
+
# The fallback is deliberate and ordered: a pinned index that 404s for a future torch
|
|
90
|
+
# release must not leave the node with NO torch, and plain PyPI is still better than
|
|
91
|
+
# nothing on a CPU node.
|
|
92
|
+
pip install -q torch --index-url https://download.pytorch.org/whl/cu124 \
|
|
93
|
+
|| pip install -q torch \
|
|
94
|
+
|| echo "⚠ torch failed to install — this node will serve TEXT only"
|
|
95
|
+
pip install -q diffusers transformers accelerate safetensors soundfile \
|
|
96
|
+
|| echo "⚠ media libraries failed to install — this node will serve TEXT only"
|
|
97
|
+
|
|
98
|
+
# PER-MODALITY RUNTIMES. Each generator's `available()` demands its OWN runtime, and a node
|
|
99
|
+
# that advertises without it fails every job routed to it — `audiogen` says so in as many
|
|
100
|
+
# words: "ANY GPU node advertised audio and then failed every job". So the checks and the
|
|
101
|
+
# installs are kept in step, modality by modality:
|
|
102
|
+
# video -> videogen.available() requires shutil.which("ffmpeg")
|
|
103
|
+
# audio -> audiogen.speech_runtime_available() imports kokoro or chatterbox; the registry
|
|
104
|
+
# resolves speech to kokoro-82m (hexgrad/Kokoro-82M), so kokoro is the one to have
|
|
105
|
+
case ",${MEDIA_WANT}," in
|
|
106
|
+
*,video,*)
|
|
107
|
+
if command -v ffmpeg >/dev/null 2>&1; then
|
|
108
|
+
echo "▶ ffmpeg already present"
|
|
109
|
+
else
|
|
110
|
+
echo "▶ installing ffmpeg (videogen requires it on PATH)"
|
|
111
|
+
# APT FIRST, THEN A PIP-SHIPPED STATIC BINARY. apt needs root, a working mirror and no
|
|
112
|
+
# held lock; in a node container any of the three can be missing, and the only symptom
|
|
113
|
+
# is `shutil.which("ffmpeg")` staying false while the log says nothing. imageio-ffmpeg
|
|
114
|
+
# carries its own static build and needs none of that — but it does not put it on
|
|
115
|
+
# PATH, which is the only thing videogen.available() looks at, so it is linked.
|
|
116
|
+
(apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ffmpeg) \
|
|
117
|
+
>/dev/null 2>&1 || true
|
|
118
|
+
if ! command -v ffmpeg >/dev/null 2>&1; then
|
|
119
|
+
echo "▶ apt could not provide ffmpeg; falling back to imageio-ffmpeg"
|
|
120
|
+
pip install -q imageio-ffmpeg >/dev/null 2>&1 || true
|
|
121
|
+
FF="$(python -c "import imageio_ffmpeg,sys; sys.stdout.write(imageio_ffmpeg.get_ffmpeg_exe())" 2>/dev/null || true)"
|
|
122
|
+
if [ -n "$FF" ] && [ -x "$FF" ]; then
|
|
123
|
+
for d in /usr/local/bin "$HOME/.local/bin"; do
|
|
124
|
+
mkdir -p "$d" 2>/dev/null && ln -sf "$FF" "$d/ffmpeg" 2>/dev/null && break
|
|
125
|
+
done
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
# SAY WHAT THE CHECK WILL SEE, not what we attempted. videogen.available() asks
|
|
129
|
+
# shutil.which("ffmpeg"); anything else is a story about an install.
|
|
130
|
+
if command -v ffmpeg >/dev/null 2>&1; then
|
|
131
|
+
echo "▶ ffmpeg on PATH at $(command -v ffmpeg)"
|
|
132
|
+
else
|
|
133
|
+
echo "⚠ ffmpeg still NOT on PATH — VIDEO will not be advertised"
|
|
134
|
+
fi
|
|
135
|
+
fi ;;
|
|
136
|
+
esac
|
|
137
|
+
case ",${MEDIA_WANT}," in
|
|
138
|
+
*,audio,*)
|
|
139
|
+
if python -c "import kokoro" >/dev/null 2>&1; then
|
|
140
|
+
echo "▶ kokoro already present"
|
|
141
|
+
else
|
|
142
|
+
echo "▶ installing kokoro (the registry's speech model is kokoro-82m)"
|
|
143
|
+
pip install -q kokoro soundfile >/dev/null 2>&1 || true
|
|
144
|
+
# REPORT WHAT THE CHECK ASKS: audiogen.speech_runtime_available() imports kokoro.
|
|
145
|
+
if python -c "import kokoro" >/dev/null 2>&1; then
|
|
146
|
+
echo "▶ kokoro importable"
|
|
147
|
+
else
|
|
148
|
+
echo "⚠ kokoro NOT importable — SPEECH will not be advertised"
|
|
149
|
+
fi
|
|
150
|
+
fi ;;
|
|
151
|
+
esac
|
|
152
|
+
python - <<'PYCHECK' || true
|
|
153
|
+
# SAY WHETHER THE GPU IS ACTUALLY USABLE, here, at install time. Without this the only symptom
|
|
154
|
+
# is a node that quietly advertises text, and the reason sits in a warning nobody reads.
|
|
155
|
+
try:
|
|
156
|
+
import torch
|
|
157
|
+
print(f"▶ torch {torch.__version__}: cuda_available={torch.cuda.is_available()} "
|
|
158
|
+
f"devices={torch.cuda.device_count()}")
|
|
159
|
+
if not torch.cuda.is_available():
|
|
160
|
+
print("⚠ torch cannot see a GPU — media modalities will NOT be advertised. "
|
|
161
|
+
"Usually a driver/wheel CUDA mismatch; check the warning above.")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"⚠ torch import failed after install: {type(e).__name__}: {e}")
|
|
164
|
+
PYCHECK
|
|
165
|
+
fi ;;
|
|
166
|
+
esac
|
|
167
|
+
|
|
168
|
+
# --- inference engine (Ollama) + a CPU-runnable model -----------------------
|
|
169
|
+
# The node runs a REAL open-weight model through Ollama (see frutebox/inference.py).
|
|
170
|
+
# Default model = $FRUTEBOX_OLLAMA_MODEL (the STEVAI_ spelling is still read), else
|
|
171
|
+
# gemma3:4b (matches inference.py).
|
|
172
|
+
MODEL="${FRUTEBOX_OLLAMA_MODEL:-${STEVAI_OLLAMA_MODEL:-gemma3:4b}}"
|
|
173
|
+
if ! command -v ollama >/dev/null 2>&1; then
|
|
174
|
+
case "$(uname -s)" in
|
|
175
|
+
Linux)
|
|
176
|
+
echo "▶ installing Ollama (Linux) …"
|
|
177
|
+
curl -fsSL https://ollama.com/install.sh | sh ;;
|
|
178
|
+
Darwin)
|
|
179
|
+
echo "⚠ Ollama not found. Install it once on macOS from https://ollama.com/download"
|
|
180
|
+
echo " (or 'brew install ollama'), then re-run ./install.sh." ;;
|
|
181
|
+
*)
|
|
182
|
+
echo "⚠ Ollama not found and no auto-installer for this OS — see https://ollama.com" ;;
|
|
183
|
+
esac
|
|
184
|
+
fi
|
|
185
|
+
# Least-privilege engine posture (see frutebox/engine_guard.py):
|
|
186
|
+
# * a NODE-PRIVATE model store, so network jobs never read, evict or grow the
|
|
187
|
+
# models you pulled for yourself;
|
|
188
|
+
# * bound to LOOPBACK, so nothing off this machine can reach the engine;
|
|
189
|
+
# * started with a MINIMAL environment via `env -i`, so the engine does not inherit
|
|
190
|
+
# the cloud credentials and API tokens sitting in your shell.
|
|
191
|
+
# THE VARIABLE NAME dual-reads; THE DEFAULT PATH DOES NOT MOVE. `$HOME/.stevai` holds the pulled
|
|
192
|
+
# model store — tens of GB on a real node — so changing the path orphans it and re-downloads
|
|
193
|
+
# everything on the next boot. That is a data migration, not a rename (unfinished.md §6b). Both
|
|
194
|
+
# names are exported so either spelling can be read downstream.
|
|
195
|
+
FRUTEBOX_HOME="${FRUTEBOX_HOME:-${STEVAI_HOME:-$HOME/.stevai}}"
|
|
196
|
+
export FRUTEBOX_HOME
|
|
197
|
+
# STILL EXPORTED, and not an oversight: anything downstream that reads the old spelling must
|
|
198
|
+
# keep working until the whole fleet has been rebuilt. Deleting this line is what "finishing
|
|
199
|
+
# the rename" looks like, and it would break every running node.
|
|
200
|
+
export STEVAI_HOME="$FRUTEBOX_HOME"
|
|
201
|
+
export OLLAMA_MODELS="${OLLAMA_MODELS:-$FRUTEBOX_HOME/models}"
|
|
202
|
+
export OLLAMA_HOST="${OLLAMA_HOST:-127.0.0.1:11434}"
|
|
203
|
+
mkdir -p "$OLLAMA_MODELS" && chmod 700 "$FRUTEBOX_HOME" 2>/dev/null || true
|
|
204
|
+
|
|
205
|
+
frutebox_start_engine() {
|
|
206
|
+
curl -sf "http://$OLLAMA_HOST/api/tags" >/dev/null 2>&1 && return 0
|
|
207
|
+
env -i HOME="$HOME" PATH="$PATH" \\
|
|
208
|
+
${LANG:+LANG="$LANG"} ${TMPDIR:+TMPDIR="$TMPDIR"} \\
|
|
209
|
+
${LD_LIBRARY_PATH:+LD_LIBRARY_PATH="$LD_LIBRARY_PATH"} \\
|
|
210
|
+
${CUDA_VISIBLE_DEVICES:+CUDA_VISIBLE_DEVICES="$CUDA_VISIBLE_DEVICES"} \\
|
|
211
|
+
OLLAMA_HOST="$OLLAMA_HOST" OLLAMA_MODELS="$OLLAMA_MODELS" \\
|
|
212
|
+
OLLAMA_ORIGINS="http://127.0.0.1" \\
|
|
213
|
+
nohup ollama serve >"$FRUTEBOX_HOME/ollama.log" 2>&1 &
|
|
214
|
+
for i in $(seq 1 60); do
|
|
215
|
+
curl -sf "http://$OLLAMA_HOST/api/tags" >/dev/null 2>&1 && return 0; sleep 1
|
|
216
|
+
done
|
|
217
|
+
return 1
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if command -v ollama >/dev/null 2>&1; then
|
|
221
|
+
echo "▶ starting Ollama and pulling model: $MODEL …"
|
|
222
|
+
frutebox_start_engine || echo "⚠ engine did not come up — run-node.sh will retry at launch."
|
|
223
|
+
ollama pull "$MODEL" || echo "⚠ could not pull $MODEL now — run-node.sh will retry at launch."
|
|
224
|
+
fi
|
|
225
|
+
echo
|
|
226
|
+
echo "✅ frutebox node installed."
|
|
227
|
+
echo " Start sharing: ./run-node.sh --name my-node --region eu-west \\\\"
|
|
228
|
+
echo " --gateway-url ws://<gateway-host>/provider/ws"
|
|
229
|
+
echo " To point a laptop at the production gateway instead, set FRUTEBOX_GATEWAY_WS:"
|
|
230
|
+
echo " FRUTEBOX_GATEWAY_WS=wss://<gateway-host>/provider/ws ./run-node.sh --name my-laptop"
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
_RUN_NODE = """#!/usr/bin/env bash
|
|
234
|
+
# Launch the frutebox node. Any flags are passed straight to the provider agent.
|
|
235
|
+
#
|
|
236
|
+
# Gateway: pass --gateway-url, or set FRUTEBOX_GATEWAY_WS (the provider reads it when
|
|
237
|
+
# the flag is absent, see frutebox/provider.py) to point a laptop at the real
|
|
238
|
+
# production gateway, e.g.:
|
|
239
|
+
# FRUTEBOX_GATEWAY_WS=wss://<gateway-host>/provider/ws ./run-node.sh --name my-laptop
|
|
240
|
+
#
|
|
241
|
+
# Admission: if you were issued a signed node credential, drop it in a file next to
|
|
242
|
+
# this script as `node-credential` (or set FRUTEBOX_NODE_CREDENTIAL / pass
|
|
243
|
+
# --credential-file). The node presents it on connect and the gateway admits it
|
|
244
|
+
# immediately — no manual approval to wait for, and it survives a control-plane
|
|
245
|
+
# restart. Without one the node still connects and waits for operator approval.
|
|
246
|
+
set -euo pipefail
|
|
247
|
+
|
|
248
|
+
# NAME YOUR OWN DEATH. Under `set -e` any non-zero command ends this script instantly and silently,
|
|
249
|
+
# and everything after it — including the `exec` that actually starts the node — simply never runs.
|
|
250
|
+
# A pod that dies here looks identical from outside to a pod whose agent started and could not
|
|
251
|
+
# reach a gateway: the container ends, the gateway logs nothing, and there is no message anywhere.
|
|
252
|
+
# Two rented pods were spent on that ambiguity (2026-09-27, 2026-09-28) before the log was even
|
|
253
|
+
# readable. `$LINENO` and `$BASH_COMMAND` cost nothing and remove the whole class.
|
|
254
|
+
#
|
|
255
|
+
# stderr on purpose: the RunPod entrypoint tees stderr into /var/log/node.log, which is the file
|
|
256
|
+
# the :8081 sidecar publishes, so this reaches whoever is watching the boot.
|
|
257
|
+
trap 'echo "run-node.sh FAILED at line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR
|
|
258
|
+
|
|
259
|
+
cd "$(dirname "$0")"
|
|
260
|
+
. .venv/bin/activate
|
|
261
|
+
|
|
262
|
+
# Signed node credential: a local `node-credential` file wins, otherwise whatever is
|
|
263
|
+
# already in the environment. Read from disk rather than the command line so the
|
|
264
|
+
# ~4.5KB grant never shows up in the process list.
|
|
265
|
+
CRED="${FRUTEBOX_NODE_CREDENTIAL:-${STEVAI_NODE_CREDENTIAL:-}}"
|
|
266
|
+
if [ -z "$CRED" ] && [ -f node-credential ]; then
|
|
267
|
+
CRED="$(tr -d '[:space:]' < node-credential)"
|
|
268
|
+
fi
|
|
269
|
+
# BOTH NAMES EXPORTED. A pod spec setting either one boots, and an unadmitted node retries on a
|
|
270
|
+
# backoff forever without saying why — so the cost of reading the wrong name here is a rented GPU
|
|
271
|
+
# that connects, is refused, and bills until someone notices.
|
|
272
|
+
export FRUTEBOX_NODE_CREDENTIAL="$CRED"
|
|
273
|
+
export STEVAI_NODE_CREDENTIAL="$CRED"
|
|
274
|
+
|
|
275
|
+
# Ensure the local Ollama engine is up and the advertised model is present, so the
|
|
276
|
+
# node can actually serve inference the moment it connects.
|
|
277
|
+
#
|
|
278
|
+
# Engine posture (mirrors install.sh — see frutebox/engine_guard.py): node-private model
|
|
279
|
+
# store, loopback-only bind, and started with a MINIMAL environment so it cannot
|
|
280
|
+
# inherit your cloud credentials. If an engine is ALREADY answering we reuse it rather
|
|
281
|
+
# than start a second one; in that case these settings are not ours to apply, so the
|
|
282
|
+
# node is protected by the payload/endpoint guard alone.
|
|
283
|
+
MODEL="${FRUTEBOX_OLLAMA_MODEL:-${STEVAI_OLLAMA_MODEL:-gemma3:4b}}"
|
|
284
|
+
# THE VARIABLE NAME dual-reads; THE DEFAULT PATH DOES NOT MOVE. `$HOME/.stevai` holds the pulled
|
|
285
|
+
# model store — tens of GB on a real node — so changing the path orphans it and re-downloads
|
|
286
|
+
# everything on the next boot. That is a data migration, not a rename (unfinished.md §6b). Both
|
|
287
|
+
# names are exported so either spelling can be read downstream.
|
|
288
|
+
FRUTEBOX_HOME="${FRUTEBOX_HOME:-${STEVAI_HOME:-$HOME/.stevai}}"
|
|
289
|
+
export FRUTEBOX_HOME
|
|
290
|
+
# STILL EXPORTED, and not an oversight: anything downstream that reads the old spelling must
|
|
291
|
+
# keep working until the whole fleet has been rebuilt. Deleting this line is what "finishing
|
|
292
|
+
# the rename" looks like, and it would break every running node.
|
|
293
|
+
export STEVAI_HOME="$FRUTEBOX_HOME"
|
|
294
|
+
export OLLAMA_MODELS="${OLLAMA_MODELS:-$FRUTEBOX_HOME/models}"
|
|
295
|
+
export OLLAMA_HOST="${OLLAMA_HOST:-127.0.0.1:11434}"
|
|
296
|
+
mkdir -p "$OLLAMA_MODELS" && chmod 700 "$FRUTEBOX_HOME" 2>/dev/null || true
|
|
297
|
+
|
|
298
|
+
if command -v ollama >/dev/null 2>&1; then
|
|
299
|
+
if ! curl -sf "http://$OLLAMA_HOST/api/tags" >/dev/null 2>&1; then
|
|
300
|
+
echo "▶ starting Ollama engine (loopback, private model store) …"
|
|
301
|
+
env -i HOME="$HOME" PATH="$PATH" \\
|
|
302
|
+
${LANG:+LANG="$LANG"} ${TMPDIR:+TMPDIR="$TMPDIR"} \\
|
|
303
|
+
${LD_LIBRARY_PATH:+LD_LIBRARY_PATH="$LD_LIBRARY_PATH"} \\
|
|
304
|
+
${CUDA_VISIBLE_DEVICES:+CUDA_VISIBLE_DEVICES="$CUDA_VISIBLE_DEVICES"} \\
|
|
305
|
+
OLLAMA_HOST="$OLLAMA_HOST" OLLAMA_MODELS="$OLLAMA_MODELS" \\
|
|
306
|
+
OLLAMA_ORIGINS="http://127.0.0.1" \\
|
|
307
|
+
nohup ollama serve >"$FRUTEBOX_HOME/ollama.log" 2>&1 &
|
|
308
|
+
for i in $(seq 1 60); do
|
|
309
|
+
curl -sf "http://$OLLAMA_HOST/api/tags" >/dev/null 2>&1 && break; sleep 1
|
|
310
|
+
done
|
|
311
|
+
fi
|
|
312
|
+
# ONE MODEL PER PULL. `$MODEL` is a COMMA-SEPARATED LIST by design — `provider.py` splits it and
|
|
313
|
+
# says so ("one card can hold several models"), and the RunPod entrypoint exports the list of
|
|
314
|
+
# everything it actually pulled. Passed whole to `ollama pull` it becomes a model named
|
|
315
|
+
# "gemma3:4b,qwen3:4b", which the engine rejects:
|
|
316
|
+
# Error: 400 Bad Request: invalid model name
|
|
317
|
+
# `|| true` then swallowed it, so the node started with the engine never warmed. Observed on a
|
|
318
|
+
# live pod 2026-09-28 — the first line the node log has ever shown, because until that day the
|
|
319
|
+
# log went to a file nothing wrote.
|
|
320
|
+
for _m in $(printf '%s' "$MODEL" | tr ',' ' '); do
|
|
321
|
+
[ -n "$_m" ] || continue
|
|
322
|
+
ollama list 2>/dev/null | grep -qF "$_m" || ollama pull "$_m" || true
|
|
323
|
+
done
|
|
324
|
+
fi
|
|
325
|
+
|
|
326
|
+
export FRUTEBOX_OLLAMA_MODEL="$MODEL"
|
|
327
|
+
# Both spellings, like the credential above: a child process reading either one must work.
|
|
328
|
+
export STEVAI_OLLAMA_MODEL="$MODEL"
|
|
329
|
+
# `-u`, AND IT IS NOT COSMETIC. The RunPod entrypoint now pipes this into `tee` so the agent's
|
|
330
|
+
# output reaches the file the :8081 sidecar serves. A pipe is not a TTY, so CPython switches
|
|
331
|
+
# stdout to BLOCK buffering — the log then appears only in 8 KB chunks, and a crash loses whatever
|
|
332
|
+
# is still in the buffer. The first run with `tee` showed exactly that: the engine warm-up error
|
|
333
|
+
# was visible and the agent's own startup lines were not, which is the same blindness in a new
|
|
334
|
+
# costume.
|
|
335
|
+
exec python -u -m frutebox.provider "$@"
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
_README = """frutebox node
|
|
339
|
+
===========
|
|
340
|
+
|
|
341
|
+
This is the software you run to contribute a machine to the frutebox network.
|
|
342
|
+
It makes a single OUTBOUND connection to a gateway (no inbound ports, NAT-friendly)
|
|
343
|
+
and runs AI jobs it receives — with your secrets never exposed, because prompts are
|
|
344
|
+
redacted before they ever reach you.
|
|
345
|
+
|
|
346
|
+
Install
|
|
347
|
+
-------
|
|
348
|
+
./install.sh
|
|
349
|
+
|
|
350
|
+
install.sh sets up a Python venv, installs the Ollama engine (auto on Linux; on
|
|
351
|
+
macOS install it once from https://ollama.com/download), and pulls a CPU-runnable
|
|
352
|
+
model (default gemma3:4b, or $FRUTEBOX_OLLAMA_MODEL). A plain laptop/desktop is
|
|
353
|
+
enough — no GPU required; the node auto-detects CPU/RAM and serves as a CPU node.
|
|
354
|
+
|
|
355
|
+
Run
|
|
356
|
+
---
|
|
357
|
+
./run-node.sh --name my-node --region eu-west \\
|
|
358
|
+
--gateway-url ws://<gateway-host>/provider/ws
|
|
359
|
+
|
|
360
|
+
Point a laptop at the production gateway with the FRUTEBOX_GATEWAY_WS env var
|
|
361
|
+
(used when --gateway-url is omitted):
|
|
362
|
+
|
|
363
|
+
FRUTEBOX_GATEWAY_WS=wss://<gateway-host>/provider/ws ./run-node.sh --name my-laptop
|
|
364
|
+
|
|
365
|
+
Other env vars: FRUTEBOX_OLLAMA_MODEL (model to pull/serve, default gemma3:4b),
|
|
366
|
+
FRUTEBOX_NODE_CREDENTIAL (your signed admission credential — or save it to a file
|
|
367
|
+
named `node-credential` beside run-node.sh, or pass --credential-file).
|
|
368
|
+
Hardware class auto-detects; override with --compute-type / --memory-gb / --cpu-cores.
|
|
369
|
+
|
|
370
|
+
Stop sharing at any time from the node dashboard (default http://127.0.0.1:8790)
|
|
371
|
+
or remotely via the operator. Requires Python 3.10+.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _add_bytes(tar: tarfile.TarFile, arcname: str, data: bytes, mode: int = 0o644) -> None:
|
|
376
|
+
info = tarfile.TarInfo(f"{_ARCHIVE_ROOT}/{arcname}")
|
|
377
|
+
info.size = len(data)
|
|
378
|
+
info.mode = mode
|
|
379
|
+
tar.addfile(info, io.BytesIO(data))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def build(dest_dir: Path | None = None) -> Path:
|
|
383
|
+
"""Build (or rebuild) the node tarball and return its path."""
|
|
384
|
+
dest_dir = dest_dir or (_PROJECT / "dist")
|
|
385
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
out = dest_dir / f"frutebox-node-{NODE_VERSION}.tar.gz"
|
|
387
|
+
|
|
388
|
+
with tarfile.open(out, "w:gz") as tar:
|
|
389
|
+
# The package source (skip caches/artefacts).
|
|
390
|
+
def _filter(ti: tarfile.TarInfo):
|
|
391
|
+
base = Path(ti.name).name
|
|
392
|
+
if "__pycache__" in ti.name or base.endswith(".pyc"):
|
|
393
|
+
return None
|
|
394
|
+
return ti
|
|
395
|
+
tar.add(_PKG_DIR, arcname=f"{_ARCHIVE_ROOT}/frutebox", filter=_filter)
|
|
396
|
+
# requirements + installer + launcher + readme.
|
|
397
|
+
req = (_PROJECT / "requirements.txt")
|
|
398
|
+
req_bytes = req.read_bytes() if req.exists() else b""
|
|
399
|
+
_add_bytes(tar, "requirements.txt", req_bytes)
|
|
400
|
+
_add_bytes(tar, "install.sh", _BUNDLED_INSTALL.encode(), mode=0o755)
|
|
401
|
+
_add_bytes(tar, "run-node.sh", _RUN_NODE.encode(), mode=0o755)
|
|
402
|
+
_add_bytes(tar, "README.txt", _README.encode())
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def get_or_build(dest_dir: Path | None = None) -> Path:
|
|
407
|
+
dest_dir = dest_dir or (_PROJECT / "dist")
|
|
408
|
+
out = dest_dir / f"frutebox-node-{NODE_VERSION}.tar.gz"
|
|
409
|
+
return out if out.exists() else build(dest_dir)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class PackageRejected(Exception):
|
|
413
|
+
"""The package manifest did not verify. The caller must NOT run the bytes it describes."""
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
class NoVerifyKeyHere(PackageRejected):
|
|
417
|
+
"""This node has no key to verify WITH. Distinct from a signature that failed, because the node's
|
|
418
|
+
decision differs — and deciding it on a substring of the message was one reworded sentence away from
|
|
419
|
+
turning a refusal into a warning."""
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def verify_package_manifest(manifest: dict, *, public_key: str = "") -> dict:
|
|
423
|
+
"""FILE 22 — check the hybrid signature on a node-package manifest before running the package.
|
|
424
|
+
|
|
425
|
+
A SIGNATURE NOBODY VERIFIES IS DECORATION, which is the whole reason this exists next to the
|
|
426
|
+
endpoint that produces one. `/node-package` signs `{version, agent_digest}` with the repo's hybrid
|
|
427
|
+
signer (Ed25519 AND ML-DSA-65, both required — see `signing.mint_ticket`); this is the other half.
|
|
428
|
+
|
|
429
|
+
⚠️ THE CALLER COMPARES THE DIGEST, and this function does not offer to. It had an `expect_digest`
|
|
430
|
+
parameter and the parameter had to go for the same reason `expect_version` did: only the caller knows
|
|
431
|
+
what a mismatch MEANS. For a running node, "same version, different digest" is signed evidence of the
|
|
432
|
+
wrong build and refuses, while "different version" is an ordinary rollout and must not — and a library
|
|
433
|
+
that rejected both would crash-loop the fleet on every release. Collapsing the two into one exception
|
|
434
|
+
inside here removed the information the decision needs. This returns the signed values; the caller
|
|
435
|
+
decides. (Another lane's `test_no_unsupplied_keyword_args` is what forced the question, by noticing the
|
|
436
|
+
parameter had only tests behind it.)
|
|
437
|
+
|
|
438
|
+
Raises `PackageRejected`. There is deliberately no "unsigned is fine" branch: a caller that wants
|
|
439
|
+
to tolerate an unsigned deployment checks `manifest["signed"]` itself and decides in the open.
|
|
440
|
+
"""
|
|
441
|
+
# Verified through `signedconfig`, which owns both the seed and the verify key. Keeping the key
|
|
442
|
+
# names in one module is the point: see `sign_blob`.
|
|
443
|
+
try:
|
|
444
|
+
claims = signedconfig.verify_blob(
|
|
445
|
+
"node-package", str(manifest.get("signature") or ""), public_key=public_key)
|
|
446
|
+
except signedconfig.NoVerifyKey as e:
|
|
447
|
+
raise NoVerifyKeyHere(str(e)) from e
|
|
448
|
+
except signedconfig.ConfigRejected as e:
|
|
449
|
+
raise PackageRejected(str(e)) from e
|
|
450
|
+
|
|
451
|
+
# THE SIGNED VALUES WIN. Reading `agent_digest` off the unsigned envelope would make the signature
|
|
452
|
+
# ornamental: an attacker who cannot forge the ticket can still edit the field beside it.
|
|
453
|
+
signed_digest = str(claims.get("agent_digest") or "")
|
|
454
|
+
if not signed_digest:
|
|
455
|
+
raise PackageRejected("the signed manifest names no agent digest")
|
|
456
|
+
if str(manifest.get("agent_digest") or "") != signed_digest:
|
|
457
|
+
raise PackageRejected(
|
|
458
|
+
"the manifest's agent_digest does not match the signed one — the envelope was edited")
|
|
459
|
+
return {"version": str(claims.get("version") or ""), "agent_digest": signed_digest}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
_TARBALL_SHA: dict = {}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def tarball_sha256(path: Path | None = None) -> str:
|
|
466
|
+
"""SHA-256 of the node tarball this control plane serves.
|
|
467
|
+
|
|
468
|
+
⚠️ THE ONE THING NOBODY COMPARED. `agent_digest` covers four modules; the TARBALL — `install.sh`,
|
|
469
|
+
`run-node.sh`, `requirements.txt` and 85 other files — was never hashed at all, and the installer is
|
|
470
|
+
`curl | tar | bash` with no check of any kind. So a fetcher had nothing to compare the bytes against
|
|
471
|
+
before executing them, whatever the manifest said.
|
|
472
|
+
|
|
473
|
+
Memoised on (path, mtime, size): the tarball is built once and served many times, and hashing a
|
|
474
|
+
multi-megabyte file per request is a cost with no reader.
|
|
475
|
+
"""
|
|
476
|
+
p = path or get_or_build()
|
|
477
|
+
st = p.stat()
|
|
478
|
+
key = (str(p), st.st_mtime_ns, st.st_size)
|
|
479
|
+
hit = _TARBALL_SHA.get(key)
|
|
480
|
+
if hit:
|
|
481
|
+
return hit
|
|
482
|
+
h = hashlib.sha256()
|
|
483
|
+
with open(p, "rb") as fh:
|
|
484
|
+
for block in iter(lambda: fh.read(1 << 20), b""):
|
|
485
|
+
h.update(block)
|
|
486
|
+
out = h.hexdigest()
|
|
487
|
+
_TARBALL_SHA.clear() # one entry: a rebuilt tarball must not leave the old hash behind
|
|
488
|
+
_TARBALL_SHA[key] = out
|
|
489
|
+
return out
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def installer_script(base_url: str) -> str:
|
|
493
|
+
"""The `curl | bash` installer served by the control plane. `base_url` is the
|
|
494
|
+
directory's public URL (no trailing slash)."""
|
|
495
|
+
base = base_url.rstrip("/")
|
|
496
|
+
return f"""#!/usr/bin/env bash
|
|
497
|
+
# frutebox node installer — fetches and installs the contributor software.
|
|
498
|
+
#
|
|
499
|
+
# ⚠️ IT COMPARES THE BYTES BEFORE IT RUNS THEM, and it did not. This was `curl | tar | bash install.sh`
|
|
500
|
+
# with no check of any kind: whatever arrived was extracted and executed as the user. The signed manifest
|
|
501
|
+
# existed and nothing on this path read it.
|
|
502
|
+
#
|
|
503
|
+
# What this closes: the bytes you downloaded are the bytes the control plane publishes. What it does NOT
|
|
504
|
+
# close, stated rather than implied: this compares against a hash SERVED BY THE SAME HOST, so it is
|
|
505
|
+
# integrity against corruption and against a swapped artifact, not attribution. The ML-DSA signature over
|
|
506
|
+
# that hash is checked by the node itself at startup (`provider._check_own_build`), because verifying a
|
|
507
|
+
# hybrid post-quantum signature in bash, before python exists, is not something this script can do.
|
|
508
|
+
set -euo pipefail
|
|
509
|
+
BASE="{base}"
|
|
510
|
+
DEST="${{FRUTEBOX_NODE_DIR:-${{STEVAI_NODE_DIR:-frutebox-node}}}}"
|
|
511
|
+
TARBALL="$(mktemp -t frutebox-node-XXXX).tar.gz"
|
|
512
|
+
|
|
513
|
+
echo "▶ asking $BASE what it publishes …"
|
|
514
|
+
MANIFEST="$(curl -fsSL "$BASE/node-package")"
|
|
515
|
+
WANT="$(printf '%s' "$MANIFEST" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tarball_sha256",""))')"
|
|
516
|
+
if [ -z "$WANT" ]; then
|
|
517
|
+
echo "✖ the control plane published no tarball hash, so the download cannot be checked." >&2
|
|
518
|
+
echo " Refusing to run unverified code. Upgrade the control plane, or install from source." >&2
|
|
519
|
+
exit 1
|
|
520
|
+
fi
|
|
521
|
+
|
|
522
|
+
echo "▶ downloading the frutebox node from $BASE …"
|
|
523
|
+
curl -fsSL "$BASE/download/frutebox-node.tar.gz" -o "$TARBALL"
|
|
524
|
+
GOT="$(shasum -a 256 "$TARBALL" | cut -d' ' -f1)"
|
|
525
|
+
if [ "$GOT" != "$WANT" ]; then
|
|
526
|
+
rm -f "$TARBALL"
|
|
527
|
+
echo "✖ the download does not match what $BASE publishes." >&2
|
|
528
|
+
echo " published: $WANT" >&2
|
|
529
|
+
echo " received: $GOT" >&2
|
|
530
|
+
echo " Nothing has been extracted or run." >&2
|
|
531
|
+
exit 1
|
|
532
|
+
fi
|
|
533
|
+
echo "▶ verified: the download matches the published package."
|
|
534
|
+
|
|
535
|
+
mkdir -p "$DEST"
|
|
536
|
+
tar -xzf "$TARBALL" -C "$DEST" --strip-components=1
|
|
537
|
+
rm -f "$TARBALL"
|
|
538
|
+
cd "$DEST"
|
|
539
|
+
bash install.sh
|
|
540
|
+
echo
|
|
541
|
+
echo "▶ installed into: $DEST"
|
|
542
|
+
"""
|