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/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
+ """