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/provider.py ADDED
@@ -0,0 +1,1867 @@
1
+ """frutebox provider node.
2
+
3
+ This is the piece an organisation or individual installs on their own server /
4
+ GPU box (the "downloadable software"). It makes a single *outbound* WebSocket
5
+ connection to a network **gateway** and receives inference jobs over it. Because
6
+ the connection is outbound, the node needs no public IP, no inbound firewall
7
+ rules and no port forwarding — it works from inside a corporate network or
8
+ behind home NAT. Prompts arrive from the gateway (the network), never from the
9
+ Google-Cloud directory.
10
+
11
+ Run:
12
+ python -m frutebox.provider --name berlin-gpu-1 --region eu-central \\
13
+ --gateway-url ws://127.0.0.1:8090/provider/ws
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import time
23
+ import uuid
24
+ from collections import deque
25
+ from pathlib import Path
26
+ from typing import Deque
27
+
28
+ import websockets
29
+
30
+ from . import attest, engine_guard, inference, packaging
31
+ from .config import CONFIG
32
+ from .envvars import env as envvar
33
+
34
+ #: Reply fields that carry CONTENT and must be sealed back to the gateway (file 22-A1). An
35
+ #: embedding is a lossy but real function of the user's text — the owner's ruling treats it as
36
+ #: user data — so it may no more travel home in the clear than the document travelled out in it.
37
+ #: `type`, `id` and `model` stay readable: the gateway matches and meters the reply on those.
38
+ #:
39
+ #: PHASE TWO of the `result` cutover adds the answer itself. A completion is the user's data
40
+ #: exactly as much as their prompt is, and it used to come home in the clear on every chat turn:
41
+ #: `content` and `reasoning` are what the model wrote, `tool_calls` are the arguments it chose
42
+ #: (a tool call carries the user's own words as often as not), `text` is one streamed delta of
43
+ #: the same answer, and `artifact` is a whole generated image/audio/video, base64 on the frame.
44
+ #:
45
+ #: WHAT DELIBERATELY STAYS READABLE: `tokens`, `prompt_tokens`, `backend` and `compute_ms`. The
46
+ #: gateway meters and reputation-scores on those, they are counts and names rather than content,
47
+ #: and they are the same class of routing metadata that `_seal_job` leaves in the clear on the
48
+ #: way out. `channel` stays readable for the same reason — it says which stream a delta belongs
49
+ #: on, not what is in it.
50
+ _SEALED_REPLY_FIELDS = ("embeddings", "content", "reasoning", "tool_calls", "text", "artifact")
51
+
52
+
53
+ def _provider_ws_url(public_url: str) -> str:
54
+ """The gateway's provider socket, derived from the https url it heartbeats to the directory.
55
+
56
+ The directory stores what a gateway calls itself (`STEVAI_GATEWAY_URL`), which is an http(s)
57
+ origin. Nodes need the WebSocket endpoint on that same origin, so the scheme is upgraded and
58
+ the well-known path appended — rather than asking every gateway to publish a second url that
59
+ could drift out of step with the first.
60
+ """
61
+ u = (public_url or "").strip().rstrip("/")
62
+ if not u:
63
+ return ""
64
+ if u.startswith("https://"):
65
+ u = "wss://" + u[len("https://"):]
66
+ elif u.startswith("http://"):
67
+ u = "ws://" + u[len("http://"):]
68
+ elif not u.startswith(("ws://", "wss://")):
69
+ return ""
70
+ return u if u.endswith("/provider/ws") else u + "/provider/ws"
71
+
72
+
73
+ def _detect_gpu_vram_gb() -> float:
74
+ """Best-effort total GPU VRAM in GB via nvidia-smi; 0.0 if unavailable.
75
+
76
+ Lets a real GPU (e.g. an A40's ~48GB) declare its true capacity so the
77
+ directory tiers it correctly instead of defaulting to the CPU/small tier.
78
+ """
79
+ try:
80
+ out = subprocess.run(
81
+ ["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
82
+ capture_output=True, text=True, timeout=5,
83
+ )
84
+ if out.returncode == 0:
85
+ mibs = [int(x) for x in out.stdout.split() if x.strip().isdigit()]
86
+ if mibs:
87
+ return round(max(mibs) / 1024.0, 1)
88
+ except Exception:
89
+ pass
90
+ return 0.0
91
+
92
+
93
+ def _detect_system_memory_gb() -> float:
94
+ """Best-effort total system RAM in GB (stdlib only); 0.0 if undetermined.
95
+
96
+ Lets an ordinary laptop/desktop declare its REAL RAM to the directory instead
97
+ of a hardcoded guess, so CPU-RAM pooling budgets are honest. Linux: parse
98
+ /proc/meminfo (MemTotal, kB). macOS/BSD: `sysctl -n hw.memsize` (bytes). Last
99
+ resort: POSIX sysconf. No new pip deps.
100
+ """
101
+ # Linux (and any OS exposing /proc/meminfo).
102
+ try:
103
+ with open("/proc/meminfo", "r") as f:
104
+ for line in f:
105
+ if line.startswith("MemTotal:"):
106
+ return round(int(line.split()[1]) / (1024.0 * 1024.0), 1)
107
+ except Exception:
108
+ pass
109
+ # macOS / BSD: hw.memsize is total physical RAM in bytes.
110
+ try:
111
+ out = subprocess.run(["sysctl", "-n", "hw.memsize"],
112
+ capture_output=True, text=True, timeout=5)
113
+ if out.returncode == 0 and out.stdout.strip().isdigit():
114
+ return round(int(out.stdout.strip()) / (1024.0 ** 3), 1)
115
+ except Exception:
116
+ pass
117
+ # POSIX fallback (many Unixes expose these sysconf names).
118
+ try:
119
+ pages = os.sysconf("SC_PHYS_PAGES")
120
+ page_size = os.sysconf("SC_PAGE_SIZE")
121
+ if pages > 0 and page_size > 0:
122
+ return round(pages * page_size / (1024.0 ** 3), 1)
123
+ except (ValueError, OSError, AttributeError):
124
+ pass
125
+ return 0.0
126
+
127
+
128
+ def _detect_cpu_cores() -> int:
129
+ """Logical CPU count via the stdlib; 1 if the OS won't say."""
130
+ return os.cpu_count() or 1
131
+
132
+
133
+ # Gateway close codes with a meaning for the client (mirrors frutebox/gateway.py).
134
+ # Blind-retrying either of these is what turned a lapsed grant into a permanent
135
+ # eviction, so they are handled explicitly in run().
136
+ _CLOSE_REJECTED = 4003 # credential refused (or attestation failed)
137
+ _CLOSE_DUPLICATE = 4004 # this node id already has a live socket on that gateway
138
+ _CLOSE_CRED_EXPIRED = 4401 # credential merely lapsed — fetch a new one
139
+ _CLOSE_ROTATED = 4005 # our OWN replacement socket took over (F-20.3)
140
+ # How long to wait before dialling again once the gateway told us the credential is
141
+ # no good. Far longer than the normal backoff: nothing changes by trying sooner, and
142
+ # a tight loop is exactly the pattern that looked like an attack.
143
+ _CRED_RETRY_S = 60.0
144
+
145
+
146
+ #: Where the node publishes whether it is ACTUALLY joined to a gateway. The RunPod status sidecar
147
+ #: serves this file, and it exists because the boot-state file could not answer the only question
148
+ #: that matters. `ready` there means "engine up, models pulled, agent about to launch" — the
149
+ #: entrypoint says so in full — and a pod on 2026-09-28 sat at `ready`, both models loaded, with
150
+ #: the gateway's provider count at 0 for its whole life. Another did the same for ~18 hours on
151
+ #: 2026-09-29, billing the entire time, and nothing on the pod could be asked "are you serving?".
152
+ #: The node is the only party that knows, so the node writes it down.
153
+ NODE_STATE_FILE = envvar("NODE_STATE_FILE", "/var/run/frutebox_node_state")
154
+
155
+ #: Rotate a socket once it reaches this age (F-20.3, make-before-break). It must sit comfortably
156
+ #: BELOW the gateway's request timeout — Cloud Run caps a WebSocket at `timeoutSeconds`, currently
157
+ #: 3600 — because the point is to replace the socket on OUR schedule, while it is still healthy,
158
+ #: rather than to discover it gone on the platform's.
159
+ SOCKET_MAX_AGE_S = float(envvar("SOCKET_MAX_AGE_S", "3000"))
160
+
161
+
162
+ def _publish_link_state(state: str) -> None:
163
+ """Record the gateway link's state. Best effort, and NEVER fatal.
164
+
165
+ A laptop node has no business failing because `/var/run` is not writable, so every error is
166
+ swallowed — an unobservable node is a smaller problem than a node that will not run.
167
+ """
168
+ try:
169
+ with open(NODE_STATE_FILE, "w") as fh:
170
+ fh.write(f"{state} {time.time():.0f}\n")
171
+ except Exception: # noqa: BLE001
172
+ pass
173
+
174
+
175
+ def _close_code(exc: BaseException) -> int:
176
+ """Best-effort WebSocket close code from a websockets exception, across versions
177
+ (`.code` on older releases, `.rcvd.code` on newer ones). 0 = unknown."""
178
+ code = getattr(exc, "code", None)
179
+ if isinstance(code, int):
180
+ return code
181
+ rcvd = getattr(exc, "rcvd", None)
182
+ code = getattr(rcvd, "code", None)
183
+ return code if isinstance(code, int) else 0
184
+
185
+
186
+ def _frame_id(msg: dict):
187
+ """A HASHABLE job id for a frame off the wire.
188
+
189
+ The gateway sends a string, but the frame is attacker-influenced data and a list or
190
+ dict there would raise ``TypeError: unhashable`` from ``set.add``/``set.discard`` —
191
+ including from the ``finally`` that releases the concurrency slot, which turns a
192
+ typo into a leaked slot.
193
+ """
194
+ jid = msg.get("id")
195
+ if isinstance(jid, (str, int, float, bool)) or jid is None:
196
+ return jid
197
+ return repr(jid)[:256]
198
+
199
+
200
+ def _load_credential(args) -> str:
201
+ """Resolve this node's signed admission credential: --credential, else the file
202
+ named by --credential-file, else $FRUTEBOX_NODE_CREDENTIAL (or the legacy
203
+ $STEVAI_NODE_CREDENTIAL), else "" (no credential).
204
+
205
+ A credential is ~4.5 KB (hybrid Ed25519 + ML-DSA-65 signatures), so the FILE form
206
+ is the practical one for a service unit or a shell — and it never lands in the
207
+ process list the way a flag does. It is a bearer grant for this node id: keep it
208
+ out of logs and never put it in a URL.
209
+ """
210
+ direct = (getattr(args, "credential", "") or "").strip()
211
+ if direct:
212
+ return direct
213
+ path = (getattr(args, "credential_file", "") or "").strip()
214
+ if path:
215
+ try:
216
+ return Path(path).read_text(encoding="utf-8").strip()
217
+ except OSError as e:
218
+ print(f"[node] WARNING: could not read --credential-file {path}: "
219
+ f"{type(e).__name__} — connecting without a credential")
220
+ return envvar("NODE_CREDENTIAL", "").strip()
221
+
222
+
223
+ class ProviderNode:
224
+ def __init__(self, args):
225
+ self.id = args.id or f"{args.region}-{uuid.uuid4().hex[:6]}"
226
+ self.name = args.name or self.id
227
+ self.region = args.region
228
+ # Hardware class. An explicit --compute-type wins; otherwise AUTO-DETECT:
229
+ # a real GPU (nvidia-smi reports VRAM) → "gpu", else fall back to "cpu" so
230
+ # a plain laptop/desktop contributes as a CPU node instead of falsely
231
+ # declaring a GPU it doesn't have.
232
+ vram_gb = _detect_gpu_vram_gb()
233
+ self.compute_type = getattr(args, "compute_type", None) or (
234
+ "gpu" if vram_gb > 0 else "cpu")
235
+ # Memory this node contributes (VRAM for a GPU, system RAM for a CPU).
236
+ # This is what lets many small CPUs POOL their RAM to hold a big model.
237
+ self.memory_gb = float(getattr(args, "memory_gb", 0.0) or 0.0)
238
+ if self.memory_gb <= 0: # not declared → measure the real hardware, don't guess
239
+ if self.compute_type == "gpu":
240
+ self.memory_gb = vram_gb or 24.0
241
+ else:
242
+ self.memory_gb = _detect_system_memory_gb() or 32.0
243
+ # CPU cores: explicit flag wins, else probe the machine (stdlib os.cpu_count()).
244
+ self.cpu_cores = int(getattr(args, "cpu_cores", 0) or 0) or _detect_cpu_cores()
245
+ self.models = [m.strip() for m in args.models.split(",") if m.strip()]
246
+ if not self.models: # advertise the concrete model this node actually runs,
247
+ # A COMMA-SEPARATED LIST, same as `--models`. One card can hold several models
248
+ # (a 48 GB A40 fits qwen2.5:32b + gemma-27b), and treating the env value as a
249
+ # single name would advertise a model called "qwen2.5:32b,gemma-27b" — which
250
+ # matches nothing in routing, so the node registers and is never routed to.
251
+ raw = envvar("OLLAMA_MODEL", "") # not "serves anything"
252
+ self.models = [m.strip() for m in raw.split(",") if m.strip()]
253
+ self.cost_per_1k = args.cost_per_1k
254
+ # Simultaneous jobs this node will actually run. Advertised to the directory AND
255
+ # enforced below — before, every inbound job frame became a task regardless, so
256
+ # one user could start unlimited generations on somebody's laptop. An explicit
257
+ # --concurrency wins; otherwise the node-governance default applies.
258
+ self.max_concurrency = max(1, int(getattr(args, "concurrency", 0)
259
+ or CONFIG.node_max_concurrent_jobs))
260
+ # Hard wall-clock ceiling per job (cancel included).
261
+ self.job_timeout_s = float(getattr(args, "job_timeout_s", 0.0)
262
+ or CONFIG.node_job_timeout_s)
263
+ self.gateway_url = args.gateway_url
264
+ # Signed admission credential (minted by the directory for THIS node id and
265
+ # handed to the machine's owner). Presented on every (re)connect; the gateway
266
+ # verifies the operator's signature and admits us without waiting for a manual
267
+ # approval that would be lost on the next control-plane restart. Optional —
268
+ # without one we fall back to the digest + approval-list path.
269
+ # `_cred_args` is kept so the credential can be RE-READ before each connect:
270
+ # dropping a fresh file at --credential-file is then enough to recover from an
271
+ # expiry, with no restart and no reconnect loop hammering the stale one.
272
+ self._cred_args = args
273
+ self.credential = _load_credential(args)
274
+ # What this node can GENERATE. Ollama chat models are text-only; a capable
275
+ # node (image/audio/video) advertises more via STEVAI_GEN_MODALITIES.
276
+ self.output_modalities = [
277
+ m.strip() for m in envvar("GEN_MODALITIES", "text").split(",")
278
+ if m.strip()] or ["text"]
279
+ # PLAN 23 §B2 — "The router never offers a video/music job to a Tier-C/edge node —
280
+ # Tier B/A GPUs only (8-16 GB+)." The refusal belongs HERE, at the advertisement,
281
+ # not only at the router: a node that advertises a modality it must then refuse
282
+ # wastes a dispatch and looks like capacity that does not exist. A machine below the
283
+ # 8 GB floor stays text-only no matter which model libraries happen to be installed
284
+ # on it, and `GEN_MODALITIES` cannot be used to opt an edge node back in.
285
+ # Guarded like every other media import in this block: an import failure here must
286
+ # degrade the node to text-only, not stop it starting. A node that serves text is
287
+ # useful; a node that refuses to boot is not.
288
+ try:
289
+ from . import mediaserving
290
+ from .imagegen import device_vram_gb
291
+ _vram = device_vram_gb()
292
+ self.media_tier = mediaserving.node_tier_for_vram(_vram)
293
+ self.output_modalities = mediaserving.media_modalities_for_node(
294
+ self.output_modalities, _vram)
295
+ _media_ok = mediaserving.tier_accepts(self.media_tier)
296
+ except Exception:
297
+ self.media_tier = "C"
298
+ self.output_modalities = [m for m in self.output_modalities
299
+ if m not in ("image", "audio", "video")] or ["text"]
300
+ _media_ok = False
301
+ if _media_ok:
302
+ # Auto-advertise generation modalities this node can actually serve (the model libs
303
+ # are only present on a capable GPU node).
304
+ try:
305
+ from . import imagegen
306
+ if imagegen.available() and "image" not in self.output_modalities:
307
+ self.output_modalities.append("image")
308
+ except Exception:
309
+ pass
310
+ try:
311
+ from . import audiogen
312
+ if audiogen.available() and "audio" not in self.output_modalities:
313
+ self.output_modalities.append("audio")
314
+ except Exception:
315
+ pass
316
+ try:
317
+ from . import videogen
318
+ if videogen.available() and "video" not in self.output_modalities:
319
+ self.output_modalities.append("video")
320
+ except Exception:
321
+ pass
322
+
323
+ # AND DROP WHAT THIS NODE CANNOT ACTUALLY DO, however it came to be listed.
324
+ #
325
+ # The three blocks above only ever ADD. A modality named in `GEN_MODALITIES` was therefore
326
+ # advertised whether or not its generator could run, and `mediaserving` says plainly why
327
+ # that is wrong: "A node that advertises a modality it must then refuse is capacity that
328
+ # does not exist: the dispatch is wasted and the router has no way to know."
329
+ #
330
+ # MEASURED 2026-10-14. The media pod's spec sets GEN_MODALITIES=text,image,audio,video, and
331
+ # its boot had just deleted the venv holding torch. So it advertised `image`, cleared the
332
+ # gateway's modality gate, took the job, and answered "No module named 'torch'" — 38 times.
333
+ # The gateway reported "no node is serving 'image'", which sent the search to capacity and
334
+ # routing while the real answer was a missing import on a node that was up and connected.
335
+ #
336
+ # Declared-but-unavailable now degrades to TEXT, which makes the gate's refusal honest and
337
+ # immediate: "no node in the network can generate 'image' yet" is true and actionable,
338
+ # where a wasted dispatch is neither.
339
+ for _mod, _mod_name in (("image", "imagegen"), ("audio", "audiogen"), ("video", "videogen")):
340
+ if _mod not in self.output_modalities:
341
+ continue
342
+ try:
343
+ _gen = __import__(f"{__package__}.{_mod_name}", fromlist=[_mod_name])
344
+ if _gen.available():
345
+ continue
346
+ _why = "its generator reports unavailable"
347
+ except Exception as _e:
348
+ _why = f"{type(_e).__name__}: {_e}"
349
+ self.output_modalities.remove(_mod)
350
+ print(f"[{self.name}] NOT advertising '{_mod}' — {_why}. "
351
+ f"The operator asked for it in GEN_MODALITIES; this node cannot serve it, and "
352
+ f"advertising it would turn every such job into a wasted dispatch.")
353
+
354
+ # Arm the engine guard for THIS machine: from here on a job may only name a
355
+ # model this node advertises. The engine has no authentication and treats an
356
+ # unknown model as one to go and download, so an unclamped model string was a
357
+ # remote-controlled disk/bandwidth spend on the owner's hardware.
358
+ #
359
+ # REFUSE TO START UNARMED. `arm_models([])` (or `[""]`) leaves the allowlist
360
+ # None, which makes `check_model` a process-wide PASS-THROUGH — the guard
361
+ # silently disarms itself and every model name a job sends reaches the engine
362
+ # verbatim. That happened for real with `--models` unset and a set-but-EMPTY
363
+ # $FRUTEBOX_OLLAMA_MODEL, because the default only applies to a *missing* var.
364
+ # A node with nothing to serve is useless anyway, so fail loudly at startup
365
+ # rather than run wide open.
366
+ serves = [m for m in [*self.models, inference.DEFAULT_MODEL] if str(m or "").strip()]
367
+ if not serves:
368
+ raise SystemExit(
369
+ "frutebox node: no model to serve — pass --models, or set "
370
+ "$FRUTEBOX_OLLAMA_MODEL to a model this machine actually has. "
371
+ "(Refusing to start: an empty allowlist would disarm the engine guard "
372
+ "and let any job name any model for the engine to download.)")
373
+ # The embedding model is armed too: `inference.embed()` goes through the same
374
+ # guard, so retrieval would otherwise be refused on every node.
375
+ engine_guard.arm_models([*serves, inference.EMBED_MODEL])
376
+ if not engine_guard.models_armed(): # belt to the check above's braces
377
+ raise SystemExit("frutebox node: engine guard failed to arm — refusing to start")
378
+
379
+ # Placement trust label, assigned by the OPERATOR and delivered by the gateway
380
+ # in the `registered` frame — never self-declared. Gates node-side tool
381
+ # execution (which is additionally off by default; see engine_guard).
382
+ self.trust = "untrusted"
383
+
384
+ self.active = 0
385
+ self.rejected_jobs = 0
386
+ # Job ids the gateway asked to cancel mid-stream (§5A live steering / client stop).
387
+ # A running stream job checks this each delta and stops — freeing the GPU and
388
+ # halting billing at the partial. The receive loop adds ids; the job clears them.
389
+ #
390
+ # A cancel is only ever recorded for a job that is IN FLIGHT right now (see
391
+ # `_inflight`). Recording any id the gateway names was an unbounded, never-swept
392
+ # set — and a cancel sent *before* the matching job frame silently killed that
393
+ # job the moment it started. Bounded by max_concurrency by construction.
394
+ self._cancel_jobs: set = set()
395
+ # Job ids this node has taken a slot for and not yet finished.
396
+ self._inflight: set = set()
397
+ #: Registered sockets per gateway url. A rotated-out socket must NOT trigger a reconnect
398
+ #: while its replacement is already holding the link — that would turn make-before-break
399
+ #: into two sockets fighting for one node id, which the gateway refuses as a duplicate.
400
+ self._live_sockets: dict = {}
401
+ #: Replacement-socket tasks, held so they are not garbage collected mid-flight.
402
+ self._rotations: set = set()
403
+ # PER-STREAM REPLY SESSIONS (file 22-A1, phase two). One `pqctransport.Session` per job
404
+ # the gateway asked for a sealed answer on, keyed by job id and dropped when the job
405
+ # ends. One handshake per stream, AEAD per frame — the owner's ruling on the measured
406
+ # per-chunk cost. Bounded by max_concurrency by construction, like `_cancel_jobs`.
407
+ self._reply_sessions: dict = {}
408
+ self.recent_ms: Deque[float] = deque(maxlen=20)
409
+ self._send_lock = asyncio.Lock()
410
+ self._stop = False
411
+
412
+ # Live stats for the local dashboard.
413
+ self.gui_port = int(getattr(args, "gui_port", 0) or 0)
414
+ self.connected = False
415
+ self.jobs_served = 0
416
+ self.tokens_served = 0
417
+ #: Per-job token counts, paired with `recent_ms`, so throughput is measured and
418
+ #: never estimated (file 16 mock audit 2026-08-31).
419
+ self.recent_tokens: Deque[int] = deque(maxlen=20)
420
+ self.started_at = time.time()
421
+
422
+ # --- helpers ----------------------------------------------------------
423
+ @property
424
+ def load(self) -> float:
425
+ return min(1.0, self.active / max(1, self.max_concurrency))
426
+
427
+ def _record_job(self, tokens: int) -> None:
428
+ self.jobs_served += 1
429
+ n = int(tokens or 0)
430
+ self.tokens_served += n
431
+ # Paired with the latency sample recorded for the same job, so `measured_tps` is
432
+ # arithmetic over real observations rather than a table of guesses.
433
+ self.recent_tokens.append(n)
434
+
435
+ # --- concurrency cap --------------------------------------------------
436
+ # Taken/released in the RECEIVE LOOP, synchronously, before the task is created:
437
+ # counting inside the task leaves a window where N frames all pass the check.
438
+ def _acquire_slot(self) -> bool:
439
+ if self.active >= self.max_concurrency:
440
+ return False
441
+ self.active += 1
442
+ return True
443
+
444
+ def _release_slot(self) -> None:
445
+ self.active = max(0, self.active - 1)
446
+
447
+ @property
448
+ def estimated_earnings(self) -> float:
449
+ # Illustrative: the node's declared cost per 1k tokens for the work done.
450
+ return round(self.tokens_served / 1000.0 * self.cost_per_1k, 2)
451
+
452
+ def stats(self) -> dict:
453
+ hw = (f"{self.cpu_cores} CPU cores · {self.memory_gb:.0f}GB RAM"
454
+ if self.compute_type == "cpu"
455
+ else f"GPU · {self.memory_gb:.0f}GB")
456
+ return {
457
+ "name": self.name,
458
+ "hardware": hw,
459
+ "region": self.region,
460
+ "type": "CPU" if self.compute_type == "cpu" else "GPU",
461
+ "load": round(self.load, 3),
462
+ "active": self.active,
463
+ "jobs": self.jobs_served,
464
+ "tokens": self.tokens_served,
465
+ "earnings": self.estimated_earnings,
466
+ "uptime": time.time() - self.started_at,
467
+ "connected": self.connected,
468
+ # Owner-visible proof the limits are real: jobs this machine refused
469
+ # because it was already at its own concurrency cap.
470
+ "rejected": self.rejected_jobs,
471
+ "concurrency_cap": self.max_concurrency,
472
+ "trust": self.trust,
473
+ }
474
+
475
+ def stop(self) -> None:
476
+ """The dashboard kill switch: stop sharing and let the process exit."""
477
+ self._stop = True
478
+ self.connected = False
479
+
480
+ @property
481
+ def avg_latency_ms(self) -> float:
482
+ return sum(self.recent_ms) / len(self.recent_ms) if self.recent_ms else 250.0
483
+
484
+ @property
485
+ def measured_tps(self) -> float:
486
+ """FILE 16/09 — REAL measured throughput: tokens actually produced divided by the
487
+ time actually spent producing them. 0.0 until this node has served real work —
488
+ the control plane then refuses to score it rather than inventing a number, which
489
+ is exactly what the removed `simulateBenchmark` used to do."""
490
+ if not self.recent_ms or not self.recent_tokens:
491
+ return 0.0
492
+ total_ms = sum(self.recent_ms)
493
+ total_tokens = sum(self.recent_tokens)
494
+ if total_ms <= 0 or total_tokens <= 0:
495
+ return 0.0
496
+ return round(total_tokens / (total_ms / 1000.0), 2)
497
+
498
+ async def _send(self, ws, msg: dict) -> None:
499
+ async with self._send_lock:
500
+ await ws.send(json.dumps(msg))
501
+
502
+ def _pqc_keys(self):
503
+ """This node's EPHEMERAL hybrid keypair for the gateway->node hop (file 22-C).
504
+
505
+ Generated once per process and never written to disk: forward secrecy means a node that
506
+ is later compromised cannot decrypt traffic recorded before the compromise. A node
507
+ without a PQC runtime simply publishes no key and the gateway decides what to do about
508
+ that (file 22-B5 — fail closed once STEVAI_PQC_REQUIRE is armed).
509
+ """
510
+ if getattr(self, "_pqc", None) is None:
511
+ try:
512
+ from . import pqctransport
513
+ self._pqc = pqctransport.generate()
514
+ except Exception as e:
515
+ print(f"[{self.name}] no post-quantum transport available: {type(e).__name__}")
516
+ self._pqc = False
517
+ return self._pqc or None
518
+
519
+ def _open_job(self, job: dict) -> dict:
520
+ """Decrypt a sealed job payload at the node boundary (file 22-A3).
521
+
522
+ This IS the authorized decryption point: the node is where the model runs, so it is the
523
+ first and only place the prompt may exist in plaintext on this machine.
524
+ """
525
+ sealed = job.get("sealed")
526
+ if not sealed:
527
+ return job
528
+ keys = self._pqc_keys()
529
+ if keys is None:
530
+ raise RuntimeError("received a sealed job but this node has no PQC transport")
531
+ from . import pqctransport
532
+ import json as _json
533
+ opened = pqctransport.open_sealed(pqctransport.from_wire(sealed), keys)
534
+ payload = _json.loads(opened.decode())
535
+ # Metadata stays as the gateway sent it; content comes from the sealed half only.
536
+ return {**{k: v for k, v in job.items() if k != "sealed"}, **payload}
537
+
538
+ def _register_msg(self, rotate: bool = False) -> dict:
539
+ return {
540
+ # MAKE-BEFORE-BREAK (F-20.3). Set only on a socket opened to REPLACE this node's own
541
+ # live one. It is a request, not a right: the gateway does not act on it until the
542
+ # credential has proved this is the same node, because node ids are public.
543
+ "rotate": bool(rotate),
544
+ # File 22-C: the gateway seals this node's jobs to this key. Published as raw bytes
545
+ # in hex so it survives JSON; absent when the node has no PQC runtime.
546
+ "pqc_public": (self._pqc_keys().public().encode().hex()
547
+ if self._pqc_keys() else ""),
548
+ # File 22-A1: this build opens sealed `embed` frames and seals the vectors back.
549
+ # The key above does not say that on its own — nodes have published keys since 22-C
550
+ # while unsealing ran on the `job` path only — so the gateway waits to be TOLD
551
+ # before it seals a document to a node that might not be able to read it.
552
+ "sealed_embed": True,
553
+ "type": "register",
554
+ "id": self.id,
555
+ "name": self.name,
556
+ "region": self.region,
557
+ "compute_type": self.compute_type,
558
+ "memory_gb": self.memory_gb,
559
+ # The directory tiers a node on vram_gb (GPU) and budgets CPU-RAM
560
+ # pooling on ram_gb, so report the right one for this hardware class.
561
+ "vram_gb": self.memory_gb if self.compute_type == "gpu" else 0.0,
562
+ "ram_gb": self.memory_gb if self.compute_type == "cpu" else 0.0,
563
+ "cpu_cores": self.cpu_cores,
564
+ "models": self.models,
565
+ "cost_per_1k": self.cost_per_1k,
566
+ "max_concurrency": self.max_concurrency,
567
+ "latency_hint_ms": self.avg_latency_ms,
568
+ # Measured, not simulated: 0.0 until this node has actually served work.
569
+ "measured_tps": self.measured_tps,
570
+ # Output modalities this node can actually generate. Ollama chat models
571
+ # produce text; image/audio/video generation lives on capable nodes
572
+ # (Tier A/S) that advertise those here.
573
+ "output_modalities": self.output_modalities,
574
+ # Software-integrity attestation: a digest of this agent's own code,
575
+ # checked by the gateway against the operator's published digests.
576
+ # Telemetry once a credential is present — the signature is the anchor.
577
+ "agent_digest": attest.agent_digest(),
578
+ # Signed admission credential, if the owner was issued one. Empty string
579
+ # = legacy node: the gateway then falls back to digest + approval list.
580
+ # Re-sent on every reconnect (see run()), so restarting the process is
581
+ # all it takes to pick up a refreshed credential before this one lapses.
582
+ "credential": self.credential,
583
+ }
584
+
585
+ # --- tasks ------------------------------------------------------------
586
+ async def _heartbeat_loop(self, ws) -> None:
587
+ while not self._stop:
588
+ await self._send(ws, {
589
+ "type": "heartbeat",
590
+ "load": round(self.load, 3),
591
+ "latency_ms": round(self.avg_latency_ms, 1),
592
+ "active": self.active,
593
+ })
594
+ await asyncio.sleep(CONFIG.heartbeat_seconds)
595
+ # Kill switch was pressed — drop the connection so the node stops serving.
596
+ try:
597
+ await ws.close()
598
+ except Exception:
599
+ pass
600
+
601
+ def _seal_reply(self, reply: dict, target, job_id) -> dict:
602
+ """Seal the content half of a reply back to the gateway that asked for it (22-A1).
603
+
604
+ THE ONE PLACE A CONTENT-BEARING ANSWER LEAVES THIS NODE. Every reply frame that carries
605
+ anything the user said or the model wrote goes through here, so the policy — which fields
606
+ move into the sealed half, what stays readable, and what happens when sealing fails —
607
+ lives in one function rather than once per call site. `tests/test_transport_seal_guard.py`
608
+ is built on that: it reads the node's syntax tree and fails on an answer frame that
609
+ reaches the socket without passing through this.
610
+
611
+ `target` says HOW, and there are exactly two forms:
612
+
613
+ * A HEX PUBLIC KEY — the gateway's ephemeral reply key, as `reply_to` carries it. One
614
+ fresh X25519 ephemeral and one fresh ML-KEM encapsulation for this frame. Right for a
615
+ one-shot answer: `/v1/embeddings` sends a single reply, and a handshake amortised
616
+ over one 4 MB vector batch costs nothing worth measuring.
617
+
618
+ * A `pqctransport.Session` — one handshake for the whole stream, AEAD per frame. Right
619
+ for a completion, which is delivered as hundreds of `chunk` frames and then a
620
+ `result`: measured, per-frame sealing cost +1703 B and ~0.8 ms PER CHUNK, so a
621
+ 500-token answer paid 400 ms of CPU and 831 KiB of handshake to protect 2 KiB of
622
+ text. The owner ruled the session key for exactly that reason, and the guarantee is
623
+ unchanged — every one of those handshakes encapsulated to the SAME gateway key, and
624
+ forward secrecy comes from that key's lifetime rather than from how many times
625
+ anyone encapsulates to it.
626
+
627
+ RAISES on any failure, and the caller turns that into an error frame. There is
628
+ deliberately no "send it in the clear instead" branch: the gateway only asks for a
629
+ sealed reply on a hop it sealed, and answering that in plaintext would be a downgrade
630
+ anyone on the path could force by stripping a field.
631
+ """
632
+ from . import pqctransport
633
+ payload = {k: reply[k] for k in _SEALED_REPLY_FIELDS if k in reply}
634
+ md = {"job_id": job_id} if isinstance(job_id, (str, int)) else {}
635
+ clear = {k: v for k, v in reply.items() if k not in _SEALED_REPLY_FIELDS}
636
+ if isinstance(target, pqctransport.Session):
637
+ frame = target.seal_frame(json.dumps(payload).encode(), metadata=md)
638
+ # The handshake rides on the session's FIRST frame and on no other. That is where
639
+ # the saving is: frame 0 costs what a one-shot seal used to cost, and every frame
640
+ # after it costs an AEAD tag.
641
+ clear["sealed"] = pqctransport.session_wire_format(
642
+ frame, handshake=target.handshake if frame.seq == 0 else None)
643
+ return clear
644
+ pub = pqctransport.HybridPublic.decode(bytes.fromhex(target))
645
+ sealed = pqctransport.seal(json.dumps(payload).encode(), pub, metadata=md)
646
+ clear["sealed"] = pqctransport.wire_format(sealed)
647
+ return clear
648
+
649
+ def _begin_reply_session(self, job_id, reply_to: str):
650
+ """Open the per-stream session this job's answer comes home on, or RAISE.
651
+
652
+ The gateway's ephemeral public key arrives as `reply_to` INSIDE the sealed half of the
653
+ job — never beside it — and that is what makes it trustworthy: in the clear it could be
654
+ swapped on the path for an attacker's key, and this node would then seal the user's
655
+ answer to the attacker. AEAD-authenticated, it cannot be touched.
656
+
657
+ FAILS CLOSED. A node that cannot open a session for a job whose request asked for one
658
+ does not fall back to answering in the clear; it errors, and the gateway re-plans. The
659
+ clear answer is the thing phase two exists to stop sending.
660
+ """
661
+ from . import pqctransport
662
+ pub = pqctransport.HybridPublic.decode(bytes.fromhex(reply_to))
663
+ session, _handshake = pqctransport.begin_session(pub)
664
+ self._reply_sessions[job_id] = session
665
+ return session
666
+
667
+ async def _handle_embed(self, ws, msg: dict) -> None:
668
+ """Embed texts for the network's persistent RAG index (§5.2).
669
+
670
+ A separate frame type from `job` on purpose. An embedding is not a generation: it
671
+ runs a different model, produces vectors rather than tokens, and must NOT be
672
+ metered as a chat turn or a user would be billed twice for one document. It still
673
+ takes a concurrency slot, because it is still real work on the owner's machine.
674
+
675
+ A node whose engine has no embed endpoint (a llama.cpp chat node) answers with an
676
+ error, and the gateway re-plans onto one that can — the same shape as any other
677
+ capability the fleet is uneven about.
678
+ """
679
+ job_id = msg.get("id")
680
+ try:
681
+ was_sealed = bool(msg.get("sealed"))
682
+ # File 22-A3: the node boundary is the authorized decryption point, for an embed
683
+ # exactly as for a job — the documents in `texts` are content. `_open_job` RAISES
684
+ # when a sealed frame cannot be opened, and that is the whole point of calling it
685
+ # here: the old code read `texts` straight off the frame, so a sealed frame would
686
+ # have yielded no texts at all and been embedded as an empty batch. The gateway
687
+ # would have got a successful-looking reply for a document that was never indexed.
688
+ msg = self._open_job(msg)
689
+ texts = msg.get("texts") or msg.get("input") or []
690
+ if not isinstance(texts, list):
691
+ raise ValueError("embed frame needs a list of texts")
692
+ # Bound the batch: an unbounded list is an easy way to pin someone's CPU.
693
+ texts = [str(t or "") for t in texts][:512]
694
+ if not texts:
695
+ # AN EMPTY BATCH IS NEVER AN ANSWER. Whether the frame arrived empty or arrived
696
+ # in a shape this node could not read, embedding nothing and replying "fine"
697
+ # loses the document silently. Say so instead and let the gateway re-plan.
698
+ raise ValueError("embed frame carried no texts" +
699
+ (" after opening its sealed half" if was_sealed else ""))
700
+ vectors = await inference.embed(texts)
701
+ if len(vectors) != len(texts):
702
+ # The engine answered, but not for every chunk. The caller indexes vectors
703
+ # positionally, so a short list is a document indexed against the wrong text —
704
+ # and a silently partial index is the same defect as an empty one.
705
+ raise ValueError(f"engine returned {len(vectors)} vectors for {len(texts)} texts")
706
+ reply = {"type": "embeddings", "id": job_id,
707
+ "embeddings": vectors, "model": inference.EMBED_MODEL}
708
+ # The gateway put its own public key in the sealed half when it wants the answer
709
+ # sealed too. Absent = a gateway or a hop that could not seal this request either,
710
+ # so the clear reply is the one it is already expecting.
711
+ reply_to = msg.get("reply_to") or ""
712
+ if reply_to:
713
+ reply = self._seal_reply(reply, str(reply_to), job_id)
714
+ await self._send(ws, reply)
715
+ except BaseException as e:
716
+ # THE DETAIL STAYS ON THIS MACHINE. `{e}` used to go on the wire, and an
717
+ # exception's text routinely quotes the input that produced it — a JSON error
718
+ # quotes the offending fragment, a tokeniser error quotes the text it choked on.
719
+ # That is the model-name leak wearing a different coat, and `_seal_reply` leaves
720
+ # `message` in the CLEAR by construction, so it is plaintext on the wire. The
721
+ # class name says what kind of failure it was; the owner's own log says the rest.
722
+ print(f"[{self.name}] embed job {job_id} failed: {type(e).__name__}: {e}")
723
+ await self._send(ws, {"type": "error", "id": job_id,
724
+ "message": f"embed failed: {type(e).__name__}"})
725
+ finally:
726
+ self._inflight.discard(job_id)
727
+ self._release_slot()
728
+
729
+ def _can_serve_model(self, model: str) -> bool:
730
+ """Do we actually run this model? Compared across catalog-id and engine-tag spellings,
731
+ the same equivalence routing uses — a node advertising "qwen3:4b" must recognise a job
732
+ asking for "qwen3-4b", or it silently never races for work it can do."""
733
+ if not model or model == "stevai-default":
734
+ return True
735
+ mine = {str(m).strip().lower() for m in (self.models or []) if str(m).strip()}
736
+ if not mine:
737
+ return True # advertises nothing = serves whatever it has
738
+ want = model.strip().lower()
739
+ forms = {want, want.replace("-", ":"), want.replace(":", "-")}
740
+ return bool(forms & mine) or any(m.split(":")[0] == want.split("-")[0] for m in mine)
741
+
742
+ async def _maybe_claim(self, ws, offer: dict) -> None:
743
+ """Race for a posted job — or decline by staying silent.
744
+
745
+ A busy node simply does not claim. That is the self-deflection the design asks for, and
746
+ it is more truthful than any load number we could publish: we know our own free slots
747
+ at this instant.
748
+
749
+ When we cannot run the model but CAN still take work, we claim as a BACKSTOP: someone
750
+ has to catch the job so it never falls on the floor, and the gateway will bring the
751
+ right model up and hand over. A backstop claim never outbids a node that actually runs
752
+ the model — the gateway enforces that ordering.
753
+ """
754
+ job_id = offer.get("id")
755
+ if not isinstance(job_id, (str, int)) or job_id == "":
756
+ return
757
+ # `paused` is a DESKTOP-node concept; the Python node has no such attribute. Reading it
758
+ # directly raised AttributeError here, the async task swallowed it, and the node
759
+ # silently never claimed — every live race found nobody while every unit test passed,
760
+ # because the tests set the attribute by hand on a stub.
761
+ if getattr(self, "paused", False) or self.active >= self.max_concurrency:
762
+ return # busy or paused → self-deflect, silently
763
+ model = str(offer.get("model") or "")
764
+ serves = self._can_serve_model(model)
765
+ if not serves and not bool(offer.get("allow_backstop")):
766
+ return
767
+ await self._send(ws, {"type": "claim", "id": job_id, "backstop": not serves,
768
+ "price_quote": self._price_quote()})
769
+
770
+ #: Rough wattage by VRAM class, mirroring the settlement tiers in `economics`. A node that
771
+ #: has been benchmarked can publish its real draw instead (STEVAI_NODE_POWER_W).
772
+ _TIER_WATTS = ((16, 60.0), (48, 200.0), (80, 350.0))
773
+
774
+ def _price_quote(self) -> float:
775
+ """What this node asks to serve one hour of work, in EUR (file 02-B3).
776
+
777
+ The gateway keeps the K CHEAPEST bids and then draws uniformly among them, so this
778
+ number decides whether the node is in the running — which is exactly why the node
779
+ computes it from its OWN power draw and local tariff rather than the gateway guessing.
780
+ An operator on cheap night-rate power should be able to underbid one on expensive
781
+ daytime power, and only the node knows that.
782
+
783
+ Never raises: a node that cannot price itself still races, and is simply ranked as
784
+ unpriced (last) rather than dropped from the fleet.
785
+ """
786
+ try:
787
+ explicit = envvar("NODE_PRICE_EUR_PER_HR", "").strip()
788
+ if explicit:
789
+ return max(0.0, float(explicit))
790
+
791
+ watts = float(envvar("NODE_POWER_W", "") or 0.0)
792
+ if watts <= 0:
793
+ vram = float(getattr(self, "memory_gb", 0) or 0)
794
+ watts = 700.0
795
+ for limit, w in self._TIER_WATTS:
796
+ if vram < limit:
797
+ watts = w
798
+ break
799
+
800
+ from datetime import datetime, timezone
801
+ from .economics import rates_for
802
+ hourly = rates_for(self.region).get("per_kwh_by_hour") or [0.20] * 24
803
+ per_kwh = float(hourly[datetime.now(tz=timezone.utc).hour % len(hourly)])
804
+ return round((watts / 1000.0) * per_kwh, 6)
805
+ except Exception:
806
+ return 0.0
807
+
808
+ async def _handle_job(self, ws, job: dict) -> None:
809
+ """Run one job under this node's own wall clock, and report — never leave it running.
810
+
811
+ The timeout is the node's half of the amplification fix: the gateway already
812
+ gives up at CONFIG.job_timeout_s and re-plans onto ANOTHER node, so a job that
813
+ outlives that is pure burn on the volunteer's machine. Cancelling the coroutine
814
+ closes the engine's HTTP stream, which is what actually stops generation.
815
+ """
816
+ job_id = job.get("id")
817
+ try:
818
+ # File 22-A3: the node boundary is the authorized decryption point. Everything
819
+ # before this line has only ever held ciphertext plus routing metadata.
820
+ job = self._open_job(job)
821
+ except Exception as e:
822
+ print(f"[{self.name}] could not open sealed job {job_id}: {type(e).__name__}")
823
+ await self._send(ws, {"type": "error", "id": job_id,
824
+ "error": "sealed payload could not be opened"})
825
+ # THE SLOT WAS ALREADY TAKEN, synchronously, by the receive loop — and this branch
826
+ # used to `return` straight past the release. A node that could not open four sealed
827
+ # jobs sat pinned at load 1.0, still heartbeating as alive, exactly as the
828
+ # `_handle_shard_job` docstring describes for its own version of this bug. An error
829
+ # reply is not an exit from the slot's lifetime.
830
+ self._inflight.discard(job_id)
831
+ self._release_slot()
832
+ return
833
+ try:
834
+ # PHASE TWO of the result-seal cutover. `reply_to` came out of the SEALED half just
835
+ # above, so it is the gateway's own key and not one a relay substituted. Its
836
+ # presence is the request: seal the answer home. Its absence means the gateway could
837
+ # not seal this hop either (a keyless node, a build that cannot), and the clear
838
+ # answer is the one it is already expecting — `ACCEPT_UNSEALED_RESULT` is still True
839
+ # for exactly the length of this rollout.
840
+ #
841
+ # Opened BEFORE the job runs, so a node that cannot seal the answer burns none of
842
+ # the owner's compute discovering it at the end.
843
+ reply_to = job.get("reply_to") or ""
844
+ if reply_to:
845
+ self._begin_reply_session(job_id, str(reply_to))
846
+ except Exception as e:
847
+ print(f"[{self.name}] could not open a reply session for {job_id}: "
848
+ f"{type(e).__name__}")
849
+ await self._send(ws, {"type": "error", "id": job_id,
850
+ "message": (f"this node cannot seal its answer back to the "
851
+ f"gateway ({type(e).__name__}) and will not send "
852
+ f"it in the clear")})
853
+ self._inflight.discard(job_id)
854
+ self._release_slot()
855
+ return
856
+ try:
857
+ await asyncio.wait_for(self._run_job(ws, job), timeout=self.job_timeout_s)
858
+ except asyncio.TimeoutError:
859
+ await self._send(ws, {
860
+ "type": "error", "id": job_id,
861
+ "message": (f"job exceeded this node's {self.job_timeout_s:.0f}s limit "
862
+ f"and was cancelled"),
863
+ })
864
+ except Exception as e: # never let one bad job kill the node
865
+ # Same reason as `_handle_embed`: `{e}` put request-derived text on the wire in
866
+ # the clear. It was not hypothetical — `engine_guard.resolve_model` raises
867
+ # "model {requested!r} is not served by this node" and `_run_job` raises "this
868
+ # node does not generate {modality!r}", so the caller's own model string and
869
+ # modality came straight back out through this frame.
870
+ print(f"[{self.name}] job {job_id} failed: {type(e).__name__}: {e}")
871
+ await self._send(ws, {
872
+ "type": "error",
873
+ "id": job_id,
874
+ "message": f"job failed: {type(e).__name__}",
875
+ })
876
+ finally:
877
+ self._cancel_jobs.discard(job_id)
878
+ self._inflight.discard(job_id)
879
+ self._reply_sessions.pop(job_id, None)
880
+ self._release_slot()
881
+
882
+ async def _run_job(self, ws, job: dict) -> None:
883
+ """The actual work. Raises on any failure — `_handle_job` owns reporting,
884
+ the clock and the concurrency slot.
885
+
886
+ PHASE TWO: every answer frame below goes through `_seal_reply` when the gateway asked
887
+ for a sealed answer, and `reply_session` is what that request left behind. One session
888
+ for this whole job, so a stream pays one handshake rather than one per token delta.
889
+ `None` means the gateway did not ask — a hop neither end could seal — and the frame goes
890
+ home exactly as it did before, which is what keeps this a phase and not a flag day.
891
+ """
892
+ job_id = job["id"]
893
+ reply_session = self._reply_sessions.get(job_id)
894
+ start = time.time()
895
+ modality = (job.get("profile") or {}).get("modality") or job.get("modality") or "text"
896
+ # THE JOB DOES NOT GET TO PICK WHAT THIS MACHINE DOES. `modality` is
897
+ # attacker-controlled data off the wire, and the gateway only checks that
898
+ # *some* node in the region is capable — it does not filter the node it picked.
899
+ # Without this, a text-only laptop could be told to load SDXL / a video
900
+ # diffusion model and spend minutes of GPU it never advertised.
901
+ if modality != "text" and modality not in self.output_modalities:
902
+ raise engine_guard.EngineGuardError(
903
+ f"this node does not generate {modality!r} "
904
+ f"(serves: {', '.join(self.output_modalities)})")
905
+ if modality in ("image", "audio", "video"):
906
+ import base64
907
+ prompt = ""
908
+ for m in reversed(job.get("messages", [])):
909
+ if m.get("role") == "user":
910
+ c = m.get("content", "")
911
+ prompt = c if isinstance(c, str) else " ".join(p.get("text", "") for p in c if isinstance(p, dict))
912
+ break
913
+ # Media generation is SYNCHRONOUS, CPU/GPU-bound torch work that runs for
914
+ # seconds to minutes. Called directly it froze the whole event loop: the
915
+ # heartbeat stopped (so the gateway marked this node stale mid-job), every
916
+ # other concurrent job on the machine stalled, and — because a blocked loop
917
+ # can never run the timeout callback — `_handle_job`'s wall-clock ceiling
918
+ # could not fire. Off the loop it goes.
919
+ # Honest scope: the ceiling can now *report* and free the slot, but it
920
+ # cannot kill the thread; truly stopping torch mid-generation needs a
921
+ # child process (see engine_guard's "what is NOT contained").
922
+ if modality == "image":
923
+ from . import imagegen
924
+ # FILE 03-E2: the paid "enhance" conditions on the sketch the user's own device
925
+ # already made, rather than re-rolling from the prompt. `init` is that sketch;
926
+ # its absence is an ordinary text-to-image generation.
927
+ _init_b64 = job.get("init") or ""
928
+ _init = base64.b64decode(_init_b64) if _init_b64 else b""
929
+ _strength = float(job.get("strength") or 0.4)
930
+ # Name the model that ACTUALLY ran. This said "sdxl" unconditionally, which
931
+ # stopped being true the moment model choice moved to the registry resolver —
932
+ # and this string is what the artifact reply and the metering path report.
933
+ _img_model = imagegen.resolved_model().model_id
934
+ data, mime, backend = (
935
+ await asyncio.to_thread(imagegen.generate_image, prompt,
936
+ init=_init, strength=_strength),
937
+ "image/png", f"{_img_model}-img2img" if _init else _img_model)
938
+ elif modality == "audio":
939
+ from . import audiogen
940
+ # A consented voice sample rides with the job and is used for
941
+ # this utterance only; audiogen deletes it immediately after.
942
+ # It is the one place attacker-controlled BYTES reach a decoder on
943
+ # this machine, so it is size-capped here as well as scanned at the
944
+ # gateway — a node does not assume its gateway did the right thing.
945
+ sample_b64 = job.get("voice_sample") or ""
946
+ sample = engine_guard.check_voice_sample(
947
+ base64.b64decode(sample_b64) if sample_b64 else None)
948
+ data, mime, backend = (
949
+ await asyncio.to_thread(audiogen.generate_audio, prompt,
950
+ voice_sample=sample,
951
+ language=job.get("language", "")),
952
+ "audio/wav",
953
+ "voice-clone" if sample else "audio",
954
+ )
955
+ else:
956
+ from . import videogen
957
+ data, mime, backend = (
958
+ await asyncio.to_thread(videogen.generate_video, prompt),
959
+ "video/mp4", "video")
960
+ took_ms = (time.time() - start) * 1000.0
961
+ self.recent_ms.append(took_ms)
962
+ # A generated image/audio/video is the user's content in full, base64 on the frame —
963
+ # it is sealed like a completion, not exempted for being bytes rather than text.
964
+ artifact_reply = {
965
+ "type": "result", "id": job_id, "content": "",
966
+ "artifact": {"modality": modality, "mime": mime, "data_b64": base64.b64encode(data).decode()},
967
+ "tool_calls": [], "tokens": 0, "prompt_tokens": 0,
968
+ "backend": backend, "compute_ms": round(took_ms, 1),
969
+ }
970
+ if reply_session is not None:
971
+ artifact_reply = self._seal_reply(artifact_reply, reply_session, job_id)
972
+ await self._send(ws, artifact_reply)
973
+ return
974
+ if job.get("stream"):
975
+ # Streaming text (§5A/§5B foundation): forward the model's REASONING and
976
+ # CONTENT deltas as `chunk` frames as they arrive, then the aggregated
977
+ # `result` frame (same shape as non-stream, so the future-based path still
978
+ # resolves for metering/history). Dormant unless the gateway sets stream.
979
+ agg: dict = {}
980
+ partial: list = []
981
+ async for ev in inference.stream_turn( # dispatches: llama.cpp (Kimi) or Ollama
982
+ job.get("model", "stevai-default"), job.get("messages", []),
983
+ job.get("tools"), job.get("tool_choice"), think=job.get("think"),
984
+ think_effort=job.get("think_effort") or "",
985
+ ):
986
+ if job_id in self._cancel_jobs:
987
+ # Client bailed/steered → stop compute + billing at the partial.
988
+ # Returning closes stream_ollama's httpx stream, halting Ollama.
989
+ self._cancel_jobs.discard(job_id)
990
+ self._record_job(inference.estimate_tokens("".join(partial)))
991
+ return
992
+ if ev["type"] in ("reasoning", "content"):
993
+ if ev["type"] == "content":
994
+ partial.append(ev["text"])
995
+ # THE FRAME THE WHOLE COST QUESTION TURNED ON. One of these per token
996
+ # delta, `text` in the clear until now, and a completion spends most of
997
+ # its life here. Sealed under the per-stream session key: an AEAD tag and
998
+ # a sequence number per chunk, and the handshake only on the first.
999
+ chunk_reply = {"type": "chunk", "id": job_id,
1000
+ "channel": ev["type"], "text": ev["text"]}
1001
+ if reply_session is not None:
1002
+ chunk_reply = self._seal_reply(chunk_reply, reply_session, job_id)
1003
+ await self._send(ws, chunk_reply)
1004
+ elif ev["type"] == "done":
1005
+ agg = ev
1006
+ took_ms = (time.time() - start) * 1000.0
1007
+ self.recent_ms.append(took_ms)
1008
+ self._record_job(int(agg.get("tokens", 0)))
1009
+ # The aggregate the gateway meters and stores history from. It repeats the whole
1010
+ # answer, so sealing the deltas and leaving this in the clear would protect nothing.
1011
+ stream_reply = {
1012
+ "type": "result", "id": job_id,
1013
+ "content": agg.get("content", ""),
1014
+ "reasoning": agg.get("reasoning", ""),
1015
+ "tool_calls": agg.get("tool_calls", []),
1016
+ "tokens": int(agg.get("tokens", 0)),
1017
+ "prompt_tokens": int(agg.get("prompt_tokens", 0)),
1018
+ "backend": agg.get("backend", ""),
1019
+ "compute_ms": round(took_ms, 1),
1020
+ }
1021
+ if reply_session is not None:
1022
+ stream_reply = self._seal_reply(stream_reply, reply_session, job_id)
1023
+ await self._send(ws, stream_reply)
1024
+ return
1025
+ result = await inference.run_inference(
1026
+ model=job.get("model", "stevai-default"),
1027
+ messages=job.get("messages", []),
1028
+ region=self.region,
1029
+ load=self.load,
1030
+ compute_type=self.compute_type,
1031
+ tools=job.get("tools"),
1032
+ tool_choice=job.get("tool_choice"),
1033
+ # THE NON-STREAM PATH DROPPED THIS, and `run_inference` has accepted it all along.
1034
+ #
1035
+ # `think` is the gateway's per-turn decision about whether a question is worth
1036
+ # reasoning over. The streaming handler forwards it (see `stream_turn` above); this
1037
+ # one did not, so on the JSON path the flag was computed, sealed into the job, carried
1038
+ # across the wire, and then dropped one line before the engine.
1039
+ #
1040
+ # MEASURED 2026-09-29 against a live node. Ollama honours it: `think:false` returned
1041
+ # 3 completion tokens and an empty thinking channel, while the same prompt without it
1042
+ # returned 134 tokens and 588 characters of reasoning. Through the gateway, a
1043
+ # non-streaming turn with `quality:"speed"` — which forces the hint to False on both
1044
+ # the old and the new code — still spent ~172 hidden tokens. That gap was this
1045
+ # argument.
1046
+ think=job.get("think"),
1047
+ think_effort=job.get("think_effort") or "",
1048
+ )
1049
+ took_ms = (time.time() - start) * 1000.0
1050
+ self.recent_ms.append(took_ms)
1051
+ self._record_job(result["tokens"])
1052
+ reply = {
1053
+ "type": "result",
1054
+ "id": job_id,
1055
+ "content": result["content"],
1056
+ # THE THINKING CHANNEL. The streaming handler above has always sent this; this frame — the
1057
+ # one a NON-STREAMING job gets — omitted it, so the gateway received a result with no
1058
+ # `reasoning` key at all (measured: keys were content/tool_calls/tokens/prompt_tokens/
1059
+ # backend/compute_ms/id/type). `reasoning` is in `_SEALED_REPLY_FIELDS`, so it is sealed
1060
+ # with the answer rather than travelling beside it in the clear.
1061
+ "reasoning": result.get("reasoning", ""),
1062
+ "tool_calls": result.get("tool_calls", []), # agentic mode passthrough
1063
+ "tokens": result["tokens"],
1064
+ "prompt_tokens": result["prompt_tokens"],
1065
+ "backend": result["backend"],
1066
+ "compute_ms": round(took_ms, 1),
1067
+ }
1068
+ if reply_session is not None:
1069
+ reply = self._seal_reply(reply, reply_session, job_id)
1070
+ await self._send(ws, reply)
1071
+
1072
+ async def _handle_shard_job(self, ws, job: dict) -> None:
1073
+ """One member of a CPU pool: hold and run this node's slice of the model.
1074
+
1075
+ The pool exists when no single node has enough memory for the whole
1076
+ model. Each member owns a shard (a range of layers/experts); the lead
1077
+ (shard 0) returns the assembled text, the others return their timing so
1078
+ the gateway can report the collaboration honestly.
1079
+ """
1080
+ # EVERY exit path from here MUST hit the `finally` that releases the
1081
+ # concurrency slot — the slot was already taken, synchronously, by the receive
1082
+ # loop. Reading `job["id"]` / `int(shard_index)` *above* the try meant a
1083
+ # `shard_job` frame with no id (or `shard_index: "NaN"`) raised before the try
1084
+ # was entered: the slot leaked and no error frame was sent. Four such frames
1085
+ # pinned a node at load 1.0, still heartbeating as alive, until the process was
1086
+ # restarted. So: tolerant reads, and the shape checks live inside the try.
1087
+ job_id = job.get("id")
1088
+ shard_index = 0
1089
+ start = time.time()
1090
+ try:
1091
+ # THE POOLED PATH OPENS ITS SEALED HALF TOO. The gateway began sealing `shard_job`
1092
+ # on 2026-09-24 (the `_execute_pool` bypass fix), but this handler read `messages`
1093
+ # RAW — so a sealed shard arrived with its content in the `sealed` blob, `messages`
1094
+ # defaulted to `[]`, and every pool member ran inference on an EMPTY PROMPT and
1095
+ # returned a confident answer to nothing. Sealing one end and not the other is the
1096
+ # same defect the embed frame had: silently empty input wearing the costume of
1097
+ # protection. `_open_job` RAISES on a sealed frame it cannot open, and the `except`
1098
+ # below turns that into an error frame — which is the honest outcome, not `[]`.
1099
+ job = self._open_job(job)
1100
+ shard_index = int(job.get("shard_index", 0) or 0)
1101
+ shard_count = int(job.get("shard_count", 1) or 1)
1102
+ # THE POOLED REPLY, SEALED — the other half of the fix above, and the last frame in
1103
+ # this file that carried a completion in the clear. `reply_to` comes out of the sealed
1104
+ # half just opened, so it is the gateway's own key and not one a relay substituted.
1105
+ #
1106
+ # Opened BEFORE inference, exactly as `_handle_job` does it: a node that cannot seal
1107
+ # its answer must discover that before it spends the owner's compute, not after. The
1108
+ # `except` below turns the raise into an error frame, which is the honest outcome — a
1109
+ # node that cannot seal does NOT fall back to answering in the clear, because the clear
1110
+ # answer is the thing this phase exists to stop sending.
1111
+ #
1112
+ # Non-lead shards return timing only, but they return it on the SAME frame shape, so
1113
+ # they go through the same policy. A shape that sometimes carries a completion is
1114
+ # treated as one that always does; deciding per-frame is how the single path's leak
1115
+ # survived as long as it did.
1116
+ reply_to = job.get("reply_to") or ""
1117
+ reply_session = (self._begin_reply_session(job_id, str(reply_to))
1118
+ if reply_to else None)
1119
+ result = await asyncio.wait_for(inference.run_inference(
1120
+ model=job.get("model", "stevai-default"),
1121
+ messages=job.get("messages", []),
1122
+ region=self.region,
1123
+ load=self.load,
1124
+ compute_type=self.compute_type,
1125
+ shard=(shard_index, shard_count),
1126
+ tools=job.get("tools"),
1127
+ tool_choice=job.get("tool_choice"),
1128
+ # The SHARDED path dropped it too. A shard is still a turn, and a turn the
1129
+ # gateway decided needs no reasoning does not acquire the need by being split
1130
+ # across nodes — it would just pay for the thinking once per shard.
1131
+ think=job.get("think"),
1132
+ think_effort=job.get("think_effort") or "",
1133
+ ), timeout=self.job_timeout_s)
1134
+ took_ms = (time.time() - start) * 1000.0
1135
+ self.recent_ms.append(took_ms)
1136
+ if shard_index == 0:
1137
+ self._record_job(result["tokens"])
1138
+ shard_reply = {
1139
+ "type": "result",
1140
+ "id": job_id,
1141
+ "shard_index": shard_index,
1142
+ # Only the lead returns the assembled completion; others just timing.
1143
+ "content": result["content"] if shard_index == 0 else "",
1144
+ # Lead-only for the same reason `content` is: a pooled turn has one answer and one
1145
+ # line of thinking behind it, and a follower's slice is not either of them.
1146
+ "reasoning": result.get("reasoning", "") if shard_index == 0 else "",
1147
+ "tokens": result["tokens"] if shard_index == 0 else 0,
1148
+ "prompt_tokens": result["prompt_tokens"] if shard_index == 0 else 0,
1149
+ "backend": result["backend"],
1150
+ "compute_ms": round(took_ms, 1),
1151
+ "memory_gb": self.memory_gb,
1152
+ }
1153
+ # `content` is in `_SEALED_REPLY_FIELDS`; `shard_index`, `tokens` and the timings stay
1154
+ # readable, because the gateway needs them to assemble the pool's answer and to meter
1155
+ # it, and none of them reveals what the user said (22-A2).
1156
+ if reply_session is not None:
1157
+ shard_reply = self._seal_reply(shard_reply, reply_session, job_id)
1158
+ await self._send(ws, shard_reply)
1159
+ except asyncio.TimeoutError:
1160
+ await self._send(ws, {
1161
+ "type": "error", "id": job_id, "shard_index": shard_index,
1162
+ "message": (f"shard job exceeded this node's {self.job_timeout_s:.0f}s "
1163
+ f"limit and was cancelled"),
1164
+ })
1165
+ except Exception as e:
1166
+ # The pooled path's copy of the same leak, and it had to be fixed in the same
1167
+ # commit: a defect closed on one path and left open on the other just moves the
1168
+ # leak to whichever path the caller can reach.
1169
+ print(f"[{self.name}] shard {shard_index} of job {job_id} failed: "
1170
+ f"{type(e).__name__}: {e}")
1171
+ await self._send(ws, {
1172
+ "type": "error", "id": job_id, "shard_index": shard_index,
1173
+ "message": f"shard job failed: {type(e).__name__}",
1174
+ })
1175
+ finally:
1176
+ self._cancel_jobs.discard(job_id)
1177
+ self._inflight.discard(job_id)
1178
+ # The session dies with the job it was opened for. Left behind, it would hold a key
1179
+ # for a request that is over and let the next job to reuse this id look like one the
1180
+ # gateway had asked to have sealed.
1181
+ self._reply_sessions.pop(job_id, None)
1182
+ self._release_slot()
1183
+
1184
+ async def _serve_once(self, url: str = "", rotate: bool = False) -> None:
1185
+ target = url or self.gateway_url
1186
+ async with websockets.connect(target, max_size=8 * 1024 * 1024) as ws:
1187
+ await self._send(ws, self._register_msg(rotate))
1188
+ self.connected = True
1189
+ print(f"[{self.name}] connected to gateway {url or self.gateway_url} "
1190
+ f"(region={self.region}, models={self.models or 'any'})")
1191
+ # Connected is NOT serving: registration can still be refused (duplicate id, revoked
1192
+ # credential). Only the register reply below may claim the link is up.
1193
+ _publish_link_state("connected")
1194
+ hb = asyncio.create_task(self._heartbeat_loop(ws))
1195
+ # Open the replacement BEFORE this socket's deadline, so there is never a moment
1196
+ # when this node has no socket at all. The gateway then drains and closes this one.
1197
+ rot = asyncio.create_task(self._rotate_when_due(target))
1198
+ self._live_sockets[target] = self._live_sockets.get(target, 0) + 1
1199
+ try:
1200
+ async for raw in ws:
1201
+ # ONE BAD FRAME IS NOT A REASON TO DROP THE CONNECTION. `json.loads`
1202
+ # and `msg.get` used to sit bare inside this loop, so `'{"type":"job"'`
1203
+ # (JSONDecodeError), `'[]'`/`'"x"'`/`'null'` (AttributeError) or a
1204
+ # deeply nested array (RecursionError) tore the socket down and sent
1205
+ # the node round the reconnect backoff. Skip the frame instead; a
1206
+ # frame we cannot parse has no id to answer to anyway.
1207
+ try:
1208
+ msg = json.loads(raw)
1209
+ if not isinstance(msg, dict):
1210
+ continue
1211
+ if "id" in msg:
1212
+ msg["id"] = _frame_id(msg)
1213
+ mtype = msg.get("type")
1214
+ if mtype in ("job", "shard_job", "embed"):
1215
+ # A job we cannot name is a job we cannot answer: the result
1216
+ # frame would carry a null id the gateway can never match,
1217
+ # and the work would be pure burn on the owner's machine.
1218
+ # Refused BEFORE a slot is taken, and reported.
1219
+ if not isinstance(msg.get("id"), (str, int)) or msg["id"] == "":
1220
+ await self._send(ws, {
1221
+ "type": "error", "id": msg.get("id"),
1222
+ "message": "job frame has no usable id",
1223
+ })
1224
+ continue
1225
+ # CONCURRENCY CAP. Taken here, synchronously, before the task
1226
+ # exists: a node advertises max_concurrency and must honour it,
1227
+ # or one caller can start unlimited generations on the owner's
1228
+ # machine. Over-cap is refused immediately so the gateway
1229
+ # re-plans onto a node with room, rather than queueing.
1230
+ if not self._acquire_slot():
1231
+ self.rejected_jobs += 1
1232
+ await self._send(ws, {
1233
+ "type": "error", "id": msg.get("id"),
1234
+ "message": (f"node at capacity "
1235
+ f"({self.max_concurrency} concurrent jobs)"),
1236
+ })
1237
+ continue
1238
+ try:
1239
+ self._inflight.add(msg["id"])
1240
+ handler = (self._handle_job if mtype == "job"
1241
+ else self._handle_embed if mtype == "embed"
1242
+ else self._handle_shard_job)
1243
+ asyncio.create_task(handler(ws, msg))
1244
+ except BaseException:
1245
+ # Nothing between the acquire and the task may be able
1246
+ # to lose the slot — that is REAL 1's whole shape.
1247
+ self._inflight.discard(msg["id"])
1248
+ self._release_slot()
1249
+ raise
1250
+ elif mtype == "job_offer":
1251
+ # CAPTURE THE FLAG. The gateway posted a job; decide for OURSELVES
1252
+ # whether to race for it. Answering from our own live state is the
1253
+ # point of the design: the gateway's view of our load is always one
1254
+ # heartbeat stale, ours never is.
1255
+ #
1256
+ # Failures are LOGGED, never swallowed. A node that stops racing is
1257
+ # indistinguishable from a node that is busy, so a silent exception
1258
+ # here removes the machine from the network invisibly — which is
1259
+ # exactly what happened on 2026-08-21.
1260
+ try:
1261
+ await self._maybe_claim(ws, msg)
1262
+ except Exception as e:
1263
+ print(f"[node] could not race for {msg.get('id')}: "
1264
+ f"{type(e).__name__}: {e}")
1265
+
1266
+ elif mtype == "cancel":
1267
+ # §5A: the gateway's client bailed/steered — stop the stream job.
1268
+ # Only for a job actually running here: an id we have never
1269
+ # seen is either noise or an attempt to pre-cancel a FUTURE
1270
+ # job, and remembering every one of them was an unbounded set.
1271
+ jid = msg.get("id")
1272
+ if jid in self._inflight:
1273
+ self._cancel_jobs.add(jid)
1274
+ elif mtype == "registered":
1275
+ status = msg.get("status", "active")
1276
+ # Placement trust as the OPERATOR labelled us. Only ever lowers
1277
+ # or raises what this node will do locally (tool execution); it
1278
+ # is not something the node can claim for itself.
1279
+ self.trust = str(msg.get("trust") or "untrusted")
1280
+ note = (" — awaiting operator approval before it will "
1281
+ "receive traffic") if status == "pending" else ""
1282
+ print(f"[{self.name}] registered as id={msg.get('id')} "
1283
+ f"[{status}, trust={self.trust}]{note}")
1284
+ # THE join signal. A node `pending` operator approval is admitted but
1285
+ # gets no traffic, so it is not "serving" and must not read as such.
1286
+ _publish_link_state(
1287
+ "serving" if status == "active" else f"registered-{status}")
1288
+ # FILE 08-B2 — ANSWER THE ATTESTATION CHALLENGE, if this machine has
1289
+ # evidence to answer it with. An ordinary node has none and says nothing,
1290
+ # which is the correct behaviour: it simply never joins the TEE lane.
1291
+ await self._answer_tee_challenge(ws, str(msg.get("tee_challenge") or ""))
1292
+ elif mtype == "attested":
1293
+ # The gateway's verdict. Printed rather than swallowed because an operator
1294
+ # who has just brought up a confidential host has no other way to learn
1295
+ # whether their GPU is out of CC mode or the anchor is unpinned.
1296
+ if msg.get("lane"):
1297
+ print(f"[{self.name}] attested — serving the confidential (TEE) lane")
1298
+ elif msg.get("error"):
1299
+ print(f"[{self.name}] attestation was NOT accepted: {msg['error']}")
1300
+ except Exception as e:
1301
+ print(f"[{self.name}] ignoring unusable frame "
1302
+ f"({type(e).__name__}: {e})")
1303
+ continue
1304
+ finally:
1305
+ hb.cancel()
1306
+ rot.cancel()
1307
+ self._live_sockets[target] = max(0, self._live_sockets.get(target, 1) - 1)
1308
+ self.connected = False
1309
+
1310
+ async def _rotate_when_due(self, url: str) -> None:
1311
+ """Open a replacement socket before this one's deadline (F-20.3).
1312
+
1313
+ The replacement runs the ordinary connect loop with `rotate` set on its first register, so
1314
+ it is subject to every admission check a fresh socket is. Once the gateway installs it,
1315
+ this node's old socket is drained and closed with 4005, and `_run_one` recognises that as
1316
+ "your replacement is live" rather than as a failure to retry.
1317
+ """
1318
+ try:
1319
+ await asyncio.sleep(SOCKET_MAX_AGE_S)
1320
+ except asyncio.CancelledError:
1321
+ return
1322
+ if self._stop:
1323
+ return
1324
+ print(f"[{self.name}] socket reached {SOCKET_MAX_AGE_S:.0f}s — opening its replacement "
1325
+ f"before this one is closed")
1326
+ task = asyncio.ensure_future(self._run_one(url, first_rotate=True))
1327
+ self._rotations.add(task)
1328
+ task.add_done_callback(self._rotations.discard)
1329
+
1330
+ def _start_gui(self) -> None:
1331
+ if not self.gui_port:
1332
+ return
1333
+ try:
1334
+ from . import gui
1335
+ url = gui.start_gui(self.stats, self.stop, self.gui_port)
1336
+ print(f"[{self.name}] dashboard: open {url} in your browser")
1337
+ except Exception as e:
1338
+ print(f"[{self.name}] dashboard could not start: {type(e).__name__}: {e}")
1339
+
1340
+ def _refresh_credential(self) -> bool:
1341
+ """Re-read the credential from its source before dialling. True if it changed
1342
+ (i.e. the owner dropped in a fresh one), so we can say so and retry at once."""
1343
+ current = _load_credential(self._cred_args)
1344
+ if current and current != self.credential:
1345
+ self.credential = current
1346
+ return True
1347
+ return False
1348
+
1349
+ def gateway_urls(self) -> list:
1350
+ """Which gateways this node keeps a socket open to (file 02-A3).
1351
+
1352
+ Two by default. One socket means one gateway restart takes the node off the network until
1353
+ it reconnects; with two the restart is a routine event rather than an incident.
1354
+
1355
+ WHERE THE LIST COMES FROM (02-A3 decision table)
1356
+ "Directory returns the region's gateway list sorted by RTT probe; node takes first two;
1357
+ re-probe every 10 min." So the directory is asked first and the node measures the RTT
1358
+ itself. `STEVAI_GATEWAY_URLS` remains an explicit override, and the single
1359
+ `--gateway-url` is the last resort — a node must still come up if the directory is down.
1360
+
1361
+ REGION-SCOPED, ALWAYS. A node that dials another region's gateway advertises itself as
1362
+ capacity in that region, and the directory keeps ONE gateway per node (last heartbeat
1363
+ wins) — so the node flaps between regions and `/discover` intermittently finds nothing.
1364
+ Measured live 2026-08-28 with two nodes wrongly armed across eu-west and us-east: 7 of
1365
+ 12 turns failed with "no network gateway with an active provider".
1366
+ """
1367
+ want = max(1, int(envvar("NODE_GATEWAYS", "2") or 2))
1368
+
1369
+ regional = self._directory_gateways() # region-scoped by construction
1370
+
1371
+ override = envvar("GATEWAY_URLS", "").strip()
1372
+ urls = [u.strip() for u in override.split(",") if u.strip()]
1373
+ if urls and self._foreign_gateways():
1374
+ # THE OVERRIDE IS NOT ALLOWED OUT OF ITS REGION. The docstring above has always said
1375
+ # "REGION-SCOPED, ALWAYS" and this path did not enforce it: `STEVAI_GATEWAY_URLS` in
1376
+ # the RunPod specs lists ALL THREE regions, so an eu-west node sorted them by RTT and
1377
+ # dialled us-east.
1378
+ #
1379
+ # That is not a latency curiosity. The directory keeps ONE gateway per node, so the
1380
+ # node registered under `gw-us-east-a` while declaring `region=eu-west`, and
1381
+ # `/discover` for eu-west — which requires a node whose gateway belongs to an EU
1382
+ # gateway — correctly found nothing. Measured 2026-09-28: TWENTY consecutive turns,
1383
+ # zero landed, every one refused at `/discover`. It is also plainly wrong for data
1384
+ # residency (plan 29): EU traffic served through a US gateway.
1385
+ # DROP ONLY WHAT IS KNOWN TO BE SOMEONE ELSE'S REGION. "Not in my region's list" is
1386
+ # not the same as "in another region": an operator pinning a brand-new or private
1387
+ # gateway would be overruled by a control plane that has simply not heard of it yet,
1388
+ # and an explicit pin must not be silently discarded. So the test is positive
1389
+ # evidence of foreignness, taken from the directory's own region labels.
1390
+ foreign = self._foreign_gateways()
1391
+ kept = [u for u in urls if u not in foreign]
1392
+ dropped = [u for u in urls if u in foreign]
1393
+ if dropped:
1394
+ print(f"[{self.name}] ignoring {len(dropped)} configured gateway(s) outside "
1395
+ f"region {self.region!r}: {', '.join(dropped)}")
1396
+ urls = kept
1397
+ if not urls:
1398
+ print(f"[{self.name}] every configured gateway belongs to another region — "
1399
+ f"using region {self.region!r}'s list from the directory instead")
1400
+ urls = list(regional)
1401
+ elif not urls:
1402
+ urls = regional
1403
+ if not urls:
1404
+ # Last resort: the directory is unreachable AND nothing was configured for this
1405
+ # region. `--gateway-url` is region-specific by deployment, so it is the safe default.
1406
+ urls = [self.gateway_url]
1407
+
1408
+ # Distinct gateways first, in RTT order — the nearest is primary, the next is standby.
1409
+ seen, distinct = set(), []
1410
+ for u in urls:
1411
+ if u and u not in seen:
1412
+ seen.add(u)
1413
+ distinct.append(u)
1414
+ if len(distinct) > 1:
1415
+ # IN-REGION FIRST, THEN NEAREST. Ordering, not filtering: nothing is discarded, so an
1416
+ # operator's explicit pin is still used and an unknown gateway is still kept — the two
1417
+ # properties `test_dual_gateway_sockets` pins, and which an earlier attempt of mine
1418
+ # broke by preferring the regional list outright.
1419
+ #
1420
+ # Pure RTT order is wrong for the thing that actually matters. Measured 2026-09-29: a
1421
+ # node whose spec lists all three regions was placed far from Europe, sorted by RTT,
1422
+ # and took asia-southeast1 + us-east1 — while DECLARING `region=eu-west`. Its capacity
1423
+ # is eu-west, so the gateway correctly refused every turn ("No machine in 'us-east'
1424
+ # can run qwen3-8b right now. It is available in eu-west"), `/discover` had nothing to
1425
+ # hand out, and the node served nothing while looking perfectly healthy.
1426
+ #
1427
+ # `regional` is the directory's list for THIS node's region, so membership in it is
1428
+ # positive evidence of being in-region — the same evidence `_foreign_gateways` uses,
1429
+ # read the other way round. Latency still decides between two gateways that are both
1430
+ # in region, which is what the RTT probe was for.
1431
+ in_region = set(regional)
1432
+ distinct.sort(key=lambda u: (0 if u in in_region else 1, self._rtt_s(u)))
1433
+
1434
+ # PADDING REMOVED 2026-09-28, AFTER IT COST 18 TURNS IN 20.
1435
+ #
1436
+ # The padding repeated one url to reach `want` sockets, reasoning that two sockets to a
1437
+ # min-instances=2 service land on two different instances and give A3's redundancy. The
1438
+ # reasoning is sound and the gateway forbids the thing it relies on: `gateway.py` allows
1439
+ # ONE socket per node id PER INSTANCE, and a second closes the first with 4004 (there is a
1440
+ # reclaim path for a silent socket, and a hard refusal otherwise). Whether the two sockets
1441
+ # land on different instances is Cloud Run's choice, not ours — so padding is a coin flip
1442
+ # between "redundancy" and "the node evicts itself".
1443
+ #
1444
+ # It landed on the wrong side. With NODE_GATEWAYS=2 and one region url, the live gateway
1445
+ # log was a continuous alternation for a single node:
1446
+ # provider connected: stevai-warm-1 / provider gone: stevai-warm-1 / connected / gone …
1447
+ # and 20 consecutive real turns produced 2 answers: 17 failed at `/discover` with "no
1448
+ # gateway with an active provider", because the node's own two sockets kept knocking each
1449
+ # other off and the directory's view of it was stale most of the time.
1450
+ #
1451
+ # So: ONE SOCKET PER DISTINCT GATEWAY, never the same gateway twice. A region that
1452
+ # publishes one gateway gets one socket, and that is reported rather than papered over.
1453
+ # The instance-coverage problem the padding was reaching for is REAL and is a gateway-side
1454
+ # question (an instance holding no socket cannot serve), but it cannot be solved by making
1455
+ # a node fight itself.
1456
+ if len(distinct) < want:
1457
+ print(f"[{self.name}] region publishes {len(distinct)} distinct gateway(s); "
1458
+ f"holding {len(distinct)} socket(s), not {want} — a second socket to the SAME "
1459
+ f"gateway would be refused as a duplicate of this node id")
1460
+ return distinct[:want]
1461
+
1462
+ #: Re-probe cadence for the region's gateway list (02-A3: "re-probe every 10 min").
1463
+ REPROBE_S = float(envvar("GATEWAY_REPROBE_S", "600"))
1464
+
1465
+ def _foreign_gateways(self) -> set:
1466
+ """Gateway urls the directory says belong to a region that is NOT this node's.
1467
+
1468
+ Used to police `STEVAI_GATEWAY_URLS`, which the RunPod pod specs populate with ALL THREE
1469
+ regions. An eu-west node sorted those by RTT and dialled us-east; the directory keeps one
1470
+ gateway per node, so it registered under `gw-us-east-a` while declaring `region=eu-west`,
1471
+ and `/discover` for eu-west found no provider — twenty consecutive turns, zero landed
1472
+ (2026-09-28). It is also simply wrong for data residency (plan 29).
1473
+
1474
+ Empty on any failure, so a directory blink can never strand a node: unknown is treated as
1475
+ allowed, and only POSITIVE evidence of another region removes a url.
1476
+ """
1477
+ now = time.time()
1478
+ cached = getattr(self, "_foreign_cache", None)
1479
+ if cached and (now - cached[0]) < self.REPROBE_S:
1480
+ return set(cached[1])
1481
+ base = envvar("DIRECTORY_URL", "").rstrip("/")
1482
+ if not base:
1483
+ return set()
1484
+ try:
1485
+ import urllib.request
1486
+ with urllib.request.urlopen(f"{base}/gateways", timeout=8) as r:
1487
+ data = json.loads(r.read() or b"{}")
1488
+ except Exception as e:
1489
+ print(f"[{self.name}] could not fetch the full gateway list "
1490
+ f"({type(e).__name__}); not policing the configured list")
1491
+ return set()
1492
+ mine = (self.region or "").strip()
1493
+ out = {str(g.get("url") or "") for g in (data.get("gateways") or [])
1494
+ if str(g.get("region") or "") != mine and g.get("url")}
1495
+ self._foreign_cache = (now, set(out))
1496
+ return out
1497
+
1498
+ def _directory_gateways(self) -> list:
1499
+ """The node's own region's gateways, newest view, as provider WebSocket urls.
1500
+
1501
+ Cached for `REPROBE_S`; every failure returns [] so the caller falls back rather than
1502
+ the node failing to start because the control plane blinked.
1503
+ """
1504
+ now = time.time()
1505
+ cached = getattr(self, "_gw_cache", None)
1506
+ if cached and (now - cached[0]) < self.REPROBE_S:
1507
+ return list(cached[1])
1508
+
1509
+ base = envvar("DIRECTORY_URL", "").rstrip("/")
1510
+ if not base:
1511
+ return []
1512
+ try:
1513
+ import urllib.parse
1514
+ import urllib.request
1515
+ q = urllib.parse.urlencode({"region": self.region or ""})
1516
+ with urllib.request.urlopen(f"{base}/gateways?{q}", timeout=8) as r:
1517
+ data = json.loads(r.read() or b"{}")
1518
+ except Exception as e:
1519
+ print(f"[{self.name}] could not fetch the region gateway list ({type(e).__name__}); "
1520
+ f"falling back to the configured gateway")
1521
+ return []
1522
+
1523
+ urls = []
1524
+ for g in data.get("gateways") or []:
1525
+ # Trust but verify: the directory filters by region, and so do we. A node must never
1526
+ # hold a socket to another region's gateway, whatever the control plane returns.
1527
+ if self.region and g.get("region") and g["region"] != self.region:
1528
+ continue
1529
+ ws = _provider_ws_url(str(g.get("url") or ""))
1530
+ if ws:
1531
+ urls.append(ws)
1532
+ self._gw_cache = (now, list(urls))
1533
+ return urls
1534
+
1535
+ def _rtt_s(self, ws_url: str) -> float:
1536
+ """Round-trip time to a gateway, for primary/standby ordering. inf when unreachable, so
1537
+ a dead gateway sorts last instead of becoming the primary."""
1538
+ import urllib.request
1539
+ health = ws_url.replace("wss://", "https://").replace("ws://", "http://")
1540
+ health = health.split("/provider/ws")[0] + "/health"
1541
+ t0 = time.time()
1542
+ try:
1543
+ urllib.request.urlopen(health, timeout=4).read(1)
1544
+ return time.time() - t0
1545
+ except Exception:
1546
+ return float("inf")
1547
+
1548
+ def _check_own_build(self) -> None:
1549
+ """FILE 22 — is the code this node is about to run the code we published?
1550
+
1551
+ ⚠️ REWRITTEN AFTER A REVIEW PROVED THE FIRST VERSION HANDED AN UNAUTHENTICATED ATTACKER A
1552
+ FLEET KILL SWITCH, and turned a normal release into a crash-loop on billing pods.
1553
+
1554
+ The first version raised `REFUSING TO SERVE` on any signature that did not hold and on any digest
1555
+ that differed. Two consequences, both worse than the gap it closed:
1556
+
1557
+ 1. **Anyone who can answer `/node-package`** — DNS, a proxy, a compromised sibling — could reply
1558
+ `{"signed": true, "signature": "garbage"}` and stop every node in the fleet. NO KEY REQUIRED.
1559
+ `deploy/runpod/entrypoint.sh` restarts the agent forever, so that is an infinite crash-loop on
1560
+ a rented GPU serving nothing, which is the exact failure the comment above that loop was
1561
+ written about after it cost real money.
1562
+ 2. **A digest mismatch is the repo's own normal release order.** `config.py`'s
1563
+ `extra_agent_digests` exists precisely because "a directory only ever knows the digest of the
1564
+ code IT was built from, so any node-agent change locks out every still-running older node the
1565
+ moment the directory deploys first". The old node is SUPPOSED to keep serving through the
1566
+ rollout, and admission — which is authenticated — decides. My check refused instead.
1567
+
1568
+ So the asymmetry is now about WHAT WE CAN CONCLUDE, not about how bad the news sounds:
1569
+
1570
+ * **A signature that does not verify tells us nothing at all.** The answer is not from Frutebox, so
1571
+ it is not evidence about this build — it is noise from whoever replied. Disregard it loudly and
1572
+ carry on. Refusing would let an unauthenticated stranger decide whether we serve.
1573
+ * **A verified manifest naming OUR version with a DIFFERENT digest is authenticated evidence that
1574
+ this node is running code that was not published under that version.** That refuses.
1575
+ * **A verified manifest naming a different version is a rollout.** Report the skew and carry on;
1576
+ the gateway's admission check is the authenticated gate for that, and it already handles it.
1577
+
1578
+ The remaining gap, stated because it is not closed here: the digest covers four modules
1579
+ (`attest._AGENT_FILES`), not the tarball, and nothing verifies the package before `install.sh`
1580
+ runs. See `docs/as-built/05-known-gaps.md`.
1581
+ """
1582
+ base = envvar("DIRECTORY_URL", "").rstrip("/")
1583
+ if not base:
1584
+ # Nothing to compare against. A contributor laptop following the packaged `run-node.sh` sets
1585
+ # only the gateway URL and reaches here — which is why this returns rather than refuses, and
1586
+ # why the gateway's admission check remains the gate that actually matters.
1587
+ return
1588
+ strict = envvar("REQUIRE_SIGNED_PACKAGE", "").strip() == "1"
1589
+ try:
1590
+ import urllib.request
1591
+ with urllib.request.urlopen(f"{base}/node-package", timeout=8) as r:
1592
+ manifest = json.loads(r.read() or b"{}")
1593
+ except Exception as e:
1594
+ print(f"[{self.name}] could not fetch the package manifest ({type(e).__name__}); "
1595
+ f"the gateway still checks this build's digest at admission")
1596
+ if strict:
1597
+ raise
1598
+ return
1599
+
1600
+ mine = attest.agent_digest()
1601
+ if not manifest.get("signed"):
1602
+ msg = (f"[{self.name}] the control plane serves an UNSIGNED package manifest — this build's "
1603
+ f"digest cannot be attributed to Frutebox, only to whoever answered")
1604
+ if strict:
1605
+ raise RuntimeError(msg + " (FRUTEBOX_REQUIRE_SIGNED_PACKAGE=1)")
1606
+ print(msg)
1607
+ return
1608
+ try:
1609
+ signed = packaging.verify_package_manifest(manifest)
1610
+ except packaging.NoVerifyKeyHere as e:
1611
+ # A DISTINCT TYPE, not `if "not set" in str(e)`. That substring test was one reworded message
1612
+ # away from turning a refusal into a warning; this cannot drift.
1613
+ msg = (f"[{self.name}] cannot verify the package manifest: {e}. This node has no verify key, "
1614
+ f"so the manifest is not evidence either way. The gateway's admission check is "
1615
+ f"unaffected.")
1616
+ if strict:
1617
+ raise RuntimeError(msg + " (FRUTEBOX_REQUIRE_SIGNED_PACKAGE=1)") from e
1618
+ print(msg)
1619
+ return
1620
+ except packaging.PackageRejected as e:
1621
+ # NOT A REFUSAL. An answer we cannot authenticate is not evidence about this build, and
1622
+ # treating it as evidence is what made an unauthenticated reply a fleet-wide stop button.
1623
+ msg = (f"[{self.name}] DISREGARDING the package manifest: {e}. It is not attributable to "
1624
+ f"Frutebox, so it says nothing about this build. The gateway's admission check is the "
1625
+ f"authenticated gate and is unaffected.")
1626
+ if strict:
1627
+ raise RuntimeError(msg + " (FRUTEBOX_REQUIRE_SIGNED_PACKAGE=1)") from e
1628
+ print(msg)
1629
+ return
1630
+
1631
+ if signed["agent_digest"] == mine:
1632
+ print(f"[{self.name}] build verified against the signed manifest "
1633
+ f"(version {signed['version']}, {mine[:12]}…)")
1634
+ return
1635
+ if signed["version"] and signed["version"] != packaging.NODE_VERSION:
1636
+ # A rollout, not a wrong build. Both versions are meant to be admissible for its length.
1637
+ print(f"[{self.name}] the control plane publishes version {signed['version']} and this node "
1638
+ f"is {packaging.NODE_VERSION} — a rollout is in progress. Carrying on; admission "
1639
+ f"decides whether this digest is still accepted (see EXTRA_AGENT_DIGESTS).")
1640
+ return
1641
+ raise RuntimeError(
1642
+ f"[{self.name}] REFUSING TO SERVE: the signed manifest names version "
1643
+ f"{signed['version'] or '?'} — this node's own version — with agent digest "
1644
+ f"{signed['agent_digest'][:12]}…, and this node computes {mine[:12]}…. That is signed "
1645
+ f"evidence that this build was not published under this version. Re-fetch the package from "
1646
+ f"{base}/node-package rather than continuing.")
1647
+
1648
+ async def run(self) -> None:
1649
+ self._start_gui()
1650
+ self._check_own_build()
1651
+ urls = self.gateway_urls()
1652
+ if len(urls) == 1:
1653
+ await self._run_one(urls[0])
1654
+ return
1655
+ print(f"[{self.name}] holding {len(urls)} gateway sockets: {', '.join(urls)}")
1656
+ # Concurrency is still capped by `max_concurrency` on the node as a whole, so two
1657
+ # sockets cannot make the machine take twice the work — they only mean the job can
1658
+ # arrive by either route.
1659
+ await asyncio.gather(*(self._run_one(u) for u in urls))
1660
+
1661
+ async def _run_one(self, url: str, first_rotate: bool = False) -> None:
1662
+ backoff = 1.0
1663
+ rotate = first_rotate
1664
+ while not self._stop:
1665
+ try:
1666
+ # Pick up a replacement credential written since the last attempt —
1667
+ # this is how an expiry is actually resolved, rather than by retrying.
1668
+ self._refresh_credential()
1669
+ await self._serve_once(url, rotate=rotate)
1670
+ rotate = False # only the FIRST register of a replacement claims rotation
1671
+ backoff = 1.0
1672
+ except Exception as e:
1673
+ code = _close_code(e)
1674
+ # A REFUSED ROTATION IS NOT RE-ASSERTED. `rotate` is cleared only after a
1675
+ # SUCCESSFUL register, so a replacement socket whose rotation was refused used to
1676
+ # re-claim it on every retry — and the gateway refuses a rotation it cannot prove
1677
+ # every single time, so the two spun against each other forever.
1678
+ #
1679
+ # MEASURED 2026-10-14 on the media node, which has no credential: the gateway
1680
+ # logged "REFUSED rotation for 'frutebox-runpod-2': no verified credential" every
1681
+ # ~30s, the node logged 17 consecutive 4003s, and it held a socket almost never.
1682
+ # Media requests then failed as "no node is serving 'image'" — a node that cannot
1683
+ # keep a socket is indistinguishable from absent capacity. Note the bug is only
1684
+ # reachable WITHOUT a credential: the branch below handles a refusal for nodes
1685
+ # that have one, and it was the `and self.credential` that let this fall through
1686
+ # to the generic retry with the stale claim intact.
1687
+ #
1688
+ # Retrying as an ORDINARY socket is strictly better: it succeeds if the old socket
1689
+ # has gone, and is refused as a duplicate if it has not — in which case the live
1690
+ # socket is still serving and `_rotate_when_due` will come round again.
1691
+ if code == _CLOSE_REJECTED and rotate:
1692
+ print(f"[{self.name}] rotation was refused; retrying as an ordinary socket")
1693
+ rotate = False
1694
+ if code in (_CLOSE_CRED_EXPIRED, _CLOSE_REJECTED) and self.credential:
1695
+ # The gateway told us THIS credential is no good. Re-presenting it
1696
+ # on a 1s loop changes nothing and, before the gateway learned to
1697
+ # tell expiry from tampering, got honest nodes slashed off the
1698
+ # network in seconds. Say what to do, then wait properly.
1699
+ what = ("has EXPIRED" if code == _CLOSE_CRED_EXPIRED
1700
+ else "was REFUSED by the gateway")
1701
+ print(f"[{self.name}] node credential {what}. Get a fresh one "
1702
+ f"(the desktop app does this automatically; for this node ask "
1703
+ f"the operator / your dashboard for a new credential) and write "
1704
+ f"it to --credential-file or $FRUTEBOX_NODE_CREDENTIAL — it is "
1705
+ f"picked up on the next attempt, no restart needed. "
1706
+ f"Retrying in {_CRED_RETRY_S:.0f}s.")
1707
+ if self._stop:
1708
+ break
1709
+ # Poll the credential source while we wait so a fresh file is used
1710
+ # the moment it appears instead of after the full backoff.
1711
+ waited = 0.0
1712
+ while waited < _CRED_RETRY_S and not self._stop:
1713
+ await asyncio.sleep(min(5.0, _CRED_RETRY_S - waited))
1714
+ waited += 5.0
1715
+ if self._refresh_credential():
1716
+ print(f"[{self.name}] picked up a new credential — reconnecting")
1717
+ break
1718
+ backoff = 1.0
1719
+ continue
1720
+ if code == _CLOSE_DUPLICATE:
1721
+ print(f"[{self.name}] the gateway already has a live connection for "
1722
+ f"id '{self.id}' — another process is sharing under this id. "
1723
+ f"Retrying in {backoff:.0f}s.")
1724
+ elif code == _CLOSE_ROTATED:
1725
+ # Expected, and the whole point: this socket was replaced by one this node
1726
+ # opened itself, which is already serving. Reconnecting here would dial a
1727
+ # SECOND socket for an id that now has a live one and be refused as a
1728
+ # duplicate — so the old socket simply stands down, quietly.
1729
+ print(f"[{self.name}] socket rotated out; its replacement is serving")
1730
+ return
1731
+ else:
1732
+ print(f"[{self.name}] disconnected ({type(e).__name__}: {e}); "
1733
+ f"retrying in {backoff:.0f}s")
1734
+ _publish_link_state("disconnected")
1735
+ # NO STAND-DOWN. This used to `return` when another socket for this url was
1736
+ # registered, to avoid two tasks racing for one node id. It STRANDED THE URL, in
1737
+ # production, on 2026-09-29: the rotation task hit a refusal and returned, the
1738
+ # original had already stood down because the rotation was live at that moment,
1739
+ # and nothing was left to redial eu-west. The gateway sat at providers=0 while
1740
+ # the node reported itself healthy and served the other region.
1741
+ #
1742
+ # Racing is the cheaper failure. The gateway already enforces one socket per node
1743
+ # id and refuses the loser with 4004, which this loop treats as an ordinary
1744
+ # disconnect and retries with backoff. That is a little noise in a log. A URL
1745
+ # nothing reconnects is a region with no capacity until someone notices.
1746
+ if self._stop:
1747
+ break
1748
+ await asyncio.sleep(backoff)
1749
+ backoff = min(backoff * 2, 30.0)
1750
+ print(f"[{self.name}] stopped sharing on {url}.")
1751
+
1752
+
1753
+ async def _answer_tee_challenge(self, ws, challenge: str) -> None:
1754
+ """FILE 08-B2 (node side) — present this machine's dual attestation, if it has one.
1755
+
1756
+ SILENT WHEN THERE IS NOTHING TO PRESENT, and that is the normal case: an ordinary node has no
1757
+ evidence files, sends no frame, and never joins the confidential lane. The gateway's default is
1758
+ already "not attested", so saying nothing is the safe answer rather than a missed opportunity.
1759
+
1760
+ NOTHING HERE CAN TAKE THE NODE DOWN. Every failure — no files, unreadable files, a socket that
1761
+ rejects the frame — is caught and logged, because this node's ORDINARY traffic is unrelated to
1762
+ the confidential lane and must not depend on it. The one thing that would be worse than not
1763
+ joining the lane is a confidential-computing feature that breaks plain inference.
1764
+
1765
+ The challenge is echoed back inside the evidence, not beside it: the JWTs are produced by the
1766
+ image's attestation tooling with the nonce baked into the signed report, which is the only form
1767
+ that proves anything. A node cannot add a nonce to someone else's signed report, which is
1768
+ exactly why the gateway insists on finding it there.
1769
+ """
1770
+ if not challenge:
1771
+ return
1772
+ cpu_path, gpu_path = Path(TEE_CPU_EVIDENCE), Path(TEE_GPU_EVIDENCE)
1773
+ if not (cpu_path.exists() and gpu_path.exists()):
1774
+ return # not a confidential host; nothing to say
1775
+ try:
1776
+ cpu = cpu_path.read_text(encoding="utf-8").strip()
1777
+ gpu = gpu_path.read_text(encoding="utf-8").strip()
1778
+ except OSError as e:
1779
+ print(f"[{self.name}] TEE evidence present but unreadable ({type(e).__name__}); "
1780
+ f"staying off the confidential lane")
1781
+ return
1782
+ if not (cpu and gpu):
1783
+ print(f"[{self.name}] TEE evidence is empty; staying off the confidential lane")
1784
+ return
1785
+ try:
1786
+ await ws.send(json.dumps({
1787
+ "type": "attestation", "cpu_tee": cpu, "gpu_tee": gpu,
1788
+ # Which verification path the image used. "maa" means Azure vouched for the CPU report
1789
+ # rather than us verifying it ourselves, and the gateway refuses it unless the
1790
+ # hyperscaler sub-lane is switched on — so it is declared, never assumed.
1791
+ "cpu_path": envvar("TEE_CPU_PATH", "self"),
1792
+ "provider": envvar("TEE_PROVIDER", ""),
1793
+ # NOT the binding. The gateway checks the nonce inside the signed reports; this is
1794
+ # echoed only so an operator reading a log can match the frame to the challenge.
1795
+ "challenge_echo": challenge[:16],
1796
+ }))
1797
+ print(f"[{self.name}] presented a TEE attestation for the gateway's challenge")
1798
+ except Exception as e:
1799
+ print(f"[{self.name}] could not send the TEE attestation ({type(e).__name__}); "
1800
+ f"ordinary traffic is unaffected")
1801
+
1802
+ #: Where a confidential host leaves its attestation evidence for this agent to read.
1803
+ #:
1804
+ #: FILES, not a subprocess. Producing the evidence means running `snpguest`/`tdx-attest` and calling
1805
+ #: NVIDIA's attestation SDK, which needs the right driver, the right privileges and a network call — all
1806
+ #: of it specific to the confidential image, none of it appropriate inside a hot node agent. So the image
1807
+ #: writes the two JWTs and this reads them. That also keeps the node agent testable and means a broken
1808
+ #: attestation tool cannot take the node's ORDINARY traffic down with it.
1809
+ TEE_CPU_EVIDENCE = envvar("TEE_CPU_EVIDENCE", "/run/frutebox/cpu_tee.jwt")
1810
+ TEE_GPU_EVIDENCE = envvar("TEE_GPU_EVIDENCE", "/run/frutebox/gpu_tee.jwt")
1811
+
1812
+
1813
+ def build_parser() -> argparse.ArgumentParser:
1814
+ p = argparse.ArgumentParser(description="frutebox provider node")
1815
+ p.add_argument("--name", default=None, help="Human-friendly node name")
1816
+ p.add_argument("--id", default=None, help="Stable node id (defaults to random)")
1817
+ p.add_argument("--region", default=CONFIG.default_region,
1818
+ choices=CONFIG.regions, help="Jurisdiction this hardware sits in")
1819
+ p.add_argument("--compute-type", default=None, choices=["gpu", "cpu"],
1820
+ help="Hardware class. Omit to auto-detect (GPU if nvidia-smi "
1821
+ "reports VRAM, else CPU). GPUs are primary; CPUs are a "
1822
+ "slower fallback used when no GPU is available.")
1823
+ p.add_argument("--memory-gb", type=float, default=0.0,
1824
+ help="Memory this node contributes (VRAM for GPU, RAM for CPU). "
1825
+ "Lets many small CPUs pool their RAM to hold a big model.")
1826
+ p.add_argument("--cpu-cores", type=int, default=0,
1827
+ help="CPU core count (used to estimate CPU throughput).")
1828
+ p.add_argument("--models", default="",
1829
+ help="Comma-separated models served (empty = serves anything)")
1830
+ p.add_argument("--cost-per-1k", type=float, default=CONFIG.default_cost_per_1k,
1831
+ help="Energy+maintenance cost charged per 1000 tokens")
1832
+ p.add_argument("--concurrency", type=int, default=0,
1833
+ help="Max simultaneous jobs this node will run — advertised AND "
1834
+ "enforced. 0 = the node-governance default "
1835
+ f"({CONFIG.node_max_concurrent_jobs}, $FRUTEBOX_NODE_MAX_CONCURRENT_JOBS).")
1836
+ p.add_argument("--job-timeout-s", type=float, default=0.0,
1837
+ help="Hard wall-clock limit for one job on this machine; the job is "
1838
+ "cancelled and reported as an error. 0 = the node-governance "
1839
+ f"default ({CONFIG.node_job_timeout_s:.0f}s, $FRUTEBOX_NODE_JOB_TIMEOUT_S).")
1840
+ p.add_argument("--gateway-url", default=None,
1841
+ help="Network gateway WebSocket URL to dial into")
1842
+ p.add_argument("--credential", default="",
1843
+ help="Signed node credential issued for this node id (admits "
1844
+ "this machine without a manual operator approval). It is "
1845
+ "~4.5KB and secret-ish — prefer --credential-file.")
1846
+ p.add_argument("--credential-file", default="",
1847
+ help="Path to a file containing the signed node credential "
1848
+ "(falls back to $FRUTEBOX_NODE_CREDENTIAL).")
1849
+ p.add_argument("--gui-port", type=int, default=0,
1850
+ help="If set, serve the modern local dashboard on this port "
1851
+ "(e.g. --gui-port 8790 → http://127.0.0.1:8790)")
1852
+ return p
1853
+
1854
+
1855
+ def main() -> None:
1856
+ args = build_parser().parse_args()
1857
+ if args.gateway_url is None:
1858
+ args.gateway_url = envvar("GATEWAY_WS", "ws://127.0.0.1:8090/provider/ws")
1859
+ node = ProviderNode(args)
1860
+ try:
1861
+ asyncio.run(node.run())
1862
+ except KeyboardInterrupt:
1863
+ print(f"\n[{node.name}] shutting down")
1864
+
1865
+
1866
+ if __name__ == "__main__":
1867
+ main()