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/gui.py ADDED
@@ -0,0 +1,270 @@
1
+ """A modern, local web dashboard the node software serves to its owner.
2
+
3
+ This is deliberately a *web* GUI (served on localhost), not a desktop-widget
4
+ toolkit — so it looks clean and modern in any browser, matching the website, and
5
+ never resembles an old Tkinter/Swing window. The contributor opens it to see, in
6
+ plain terms: is my machine running, what is it doing, and what have I earned.
7
+
8
+ start_gui(stats_fn, kill_fn, port) -> runs a tiny HTTP server in a daemon
9
+ thread. stats_fn() returns a dict; kill_fn() stops the node (the "kill switch").
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hmac
14
+ import ipaddress
15
+ import json
16
+ import secrets
17
+ import threading
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from typing import Callable
20
+
21
+ # CSRF token for the destructive endpoint. `POST /kill` used to be unauthenticated on
22
+ # a predictable loopback port, so ANY web page the owner happened to visit could stop
23
+ # their node with a cross-origin form/fetch — no reply needed, so the same-origin
24
+ # policy did not help. The token is rendered into the page the server itself serves;
25
+ # a foreign page can issue the POST but cannot read the token to put in it.
26
+ #
27
+ # The token alone was not enough. A browser will happily treat a page on `evil.tld` as
28
+ # same-origin with this server once `evil.tld` re-resolves to 127.0.0.1 (DNS rebinding),
29
+ # and at that point it can simply READ the token out of the page the server renders. So
30
+ # every request is first pinned to a loopback `Host` — see `_loopback_host`.
31
+ #
32
+ # Scope, honestly: this stops drive-by CSRF and rebinding from the browser. It does NOT
33
+ # stop another process already running as the same user — that process can read the page
34
+ # (or just signal the node), and no token can change that.
35
+ _KILL_TOKEN_HEADER = "X-Stevai-Kill"
36
+
37
+ _PAGE = """<!doctype html>
38
+ <html lang="en"><head>
39
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
40
+ <title>Frutebox — your machine</title>
41
+ <style>
42
+ :root{--indigo:#4f46e5;--green:#22c55e;--ink:#10233b;--muted:#5b6b80}
43
+ *{box-sizing:border-box;margin:0;padding:0}
44
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
45
+ color:var(--ink);background:linear-gradient(160deg,#eef2ff 0%,#f7f9fc 40%,#ecfdf5 100%);
46
+ min-height:100vh;padding:32px 20px}
47
+ .wrap{max-width:920px;margin:0 auto}
48
+ .top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:28px}
49
+ .brand{font-weight:800;font-size:22px;letter-spacing:-.02em;
50
+ background:linear-gradient(90deg,#4f46e5,#22c55e);-webkit-background-clip:text;background-clip:text;color:transparent}
51
+ .pill{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;
52
+ padding:7px 14px;border-radius:999px;background:#fff;border:1px solid rgba(16,35,59,.1)}
53
+ .dot{width:9px;height:9px;border-radius:50%;background:#cbd5e1}
54
+ .dot.on{background:var(--green);box-shadow:0 0 0 4px rgba(34,197,94,.18)}
55
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}
56
+ .card{background:rgba(255,255,255,.72);backdrop-filter:blur(10px);border:1px solid rgba(16,35,59,.08);
57
+ border-radius:20px;padding:22px;box-shadow:0 12px 40px -24px rgba(16,35,59,.4)}
58
+ .label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
59
+ .big{font-size:34px;font-weight:800;letter-spacing:-.02em;margin-top:6px}
60
+ .sub{font-size:13px;color:var(--muted);margin-top:4px}
61
+ .row{display:flex;justify-content:space-between;font-size:14px;padding:7px 0;border-bottom:1px solid rgba(16,35,59,.06)}
62
+ .row:last-child{border-bottom:0}
63
+ .row b{font-weight:600}
64
+ .earn{grid-column:1/-1;background:linear-gradient(120deg,#4f46e5,#22c55e);color:#fff;border:0}
65
+ .earn .label{color:rgba(255,255,255,.85)}
66
+ .earn .sub{color:rgba(255,255,255,.85)}
67
+ .kill{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
68
+ .kill h3{font-size:17px}.kill p{font-size:13px;color:var(--muted);margin-top:4px;max-width:52ch}
69
+ .kbtn{border:0;cursor:pointer;font-weight:700;font-size:14px;color:#fff;padding:13px 22px;border-radius:999px;
70
+ background:linear-gradient(90deg,#ef4444,#dc2626);box-shadow:0 10px 24px -12px rgba(220,38,38,.8)}
71
+ .kbtn:disabled{opacity:.5;cursor:default}
72
+ .foot{text-align:center;color:var(--muted);font-size:12px;margin-top:26px}
73
+ </style></head>
74
+ <body><div class="wrap">
75
+ <div class="top">
76
+ <div class="brand">frutebox</div>
77
+ <div class="pill"><span id="dot" class="dot"></span><span id="statusText">Connecting…</span></div>
78
+ </div>
79
+ <div class="grid">
80
+ <div class="card">
81
+ <div class="label">This machine</div>
82
+ <div class="big" id="name">—</div>
83
+ <div class="sub" id="hw">—</div>
84
+ <div style="margin-top:14px">
85
+ <div class="row"><span>Location</span><b id="region">—</b></div>
86
+ <div class="row"><span>Type</span><b id="type">—</b></div>
87
+ <div class="row"><span>Current load</span><b id="load">—</b></div>
88
+ </div>
89
+ </div>
90
+ <div class="card">
91
+ <div class="label">Activity</div>
92
+ <div class="big" id="jobs">0</div>
93
+ <div class="sub">tasks completed</div>
94
+ <div style="margin-top:14px">
95
+ <div class="row"><span>Running right now</span><b id="active">0</b></div>
96
+ <div class="row"><span>Work processed</span><b id="tokens">0</b></div>
97
+ <div class="row"><span>Uptime</span><b id="uptime">—</b></div>
98
+ </div>
99
+ </div>
100
+ <div class="card earn">
101
+ <div class="label">Earned so far (estimate)</div>
102
+ <div class="big" id="earn">0.00</div>
103
+ <div class="sub">Based on the power &amp; internet your machine has contributed. Payouts switch on soon.</div>
104
+ </div>
105
+ <div class="card kill">
106
+ <div>
107
+ <h3>Stop sharing</h3>
108
+ <p>Instantly stop this machine and clear its workspace. You&apos;re always in control — turn it back on whenever you like.</p>
109
+ </div>
110
+ <button class="kbtn" id="kill" onclick="kill()">Stop &amp; clear now</button>
111
+ </div>
112
+ </div>
113
+ <div class="foot">Runs only on this machine. It reaches out to the network — it never opens your computer to the outside.</div>
114
+ </div>
115
+ <script>
116
+ const KILL_TOKEN=__KILL_TOKEN__;
117
+ function fmtUptime(s){s=Math.floor(s);const h=Math.floor(s/3600),m=Math.floor(s%3600/60);return h+"h "+m+"m"}
118
+ async function tick(){
119
+ try{
120
+ const r=await fetch('/status',{cache:'no-store'});const d=await r.json();
121
+ document.getElementById('name').textContent=d.name;
122
+ document.getElementById('hw').textContent=d.hardware;
123
+ document.getElementById('region').textContent=d.region;
124
+ document.getElementById('type').textContent=d.type;
125
+ document.getElementById('load').textContent=Math.round(d.load*100)+'%';
126
+ document.getElementById('jobs').textContent=d.jobs;
127
+ document.getElementById('active').textContent=d.active;
128
+ document.getElementById('tokens').textContent=d.tokens.toLocaleString();
129
+ document.getElementById('uptime').textContent=fmtUptime(d.uptime);
130
+ document.getElementById('earn').textContent=d.earnings.toFixed(2);
131
+ const on=d.connected;
132
+ document.getElementById('dot').className='dot'+(on?' on':'');
133
+ document.getElementById('statusText').textContent=on?'Live — earning':'Connecting…';
134
+ }catch(e){document.getElementById('statusText').textContent='Offline'}
135
+ }
136
+ async function kill(){
137
+ if(!confirm('Stop sharing this machine now? It will disconnect and clear its workspace.'))return;
138
+ document.getElementById('kill').disabled=true;
139
+ document.getElementById('kill').textContent='Stopping…';
140
+ try{await fetch('/kill',{method:'POST',headers:{'X-Stevai-Kill':KILL_TOKEN}})}catch(e){}
141
+ document.getElementById('statusText').textContent='Stopped';
142
+ document.getElementById('dot').className='dot';
143
+ }
144
+ tick();setInterval(tick,2000);
145
+ </script>
146
+ </body></html>"""
147
+
148
+
149
+ class _Handler(BaseHTTPRequestHandler):
150
+ stats_fn: Callable[[], dict]
151
+ kill_fn: Callable[[], None]
152
+ kill_token: str = ""
153
+
154
+ def _send(self, code: int, body: bytes, ctype: str) -> None:
155
+ self.send_response(code)
156
+ self.send_header("Content-Type", ctype)
157
+ self.send_header("Content-Length", str(len(body)))
158
+ self.send_header("Cache-Control", "no-store")
159
+ # No CORS headers on purpose: a foreign page may fire a request but must never
160
+ # be able to read this server's replies.
161
+ self.send_header("X-Content-Type-Options", "nosniff")
162
+ self.end_headers()
163
+ self.wfile.write(body)
164
+
165
+ def _loopback_host(self) -> bool:
166
+ """Is the request addressed to this server by a LOOPBACK name?
167
+
168
+ This is the DNS-rebinding defence, and it has to come before everything else,
169
+ because everything else is derived from the request's own headers. `evil.tld`
170
+ with a 1-second TTL that re-resolves to 127.0.0.1 is *same-origin to the
171
+ browser*: the page could fetch `/`, read `const KILL_TOKEN=…` straight out of
172
+ the HTML this server renders, and POST it back — the token and the Origin check
173
+ both pass, because `_same_origin` built the expected origin from the attacker's
174
+ own Host header. The browser will not lie about Host, so this is the one header
175
+ that pins the request to the address the owner's own tab uses.
176
+ """
177
+ raw = (self.headers.get("Host") or "").strip()
178
+ if not raw:
179
+ return False # HTTP/1.1 requires Host; no Host, no service
180
+ if raw.startswith("["): # [::1]:8790
181
+ host = raw[1:raw.index("]")] if "]" in raw else ""
182
+ else:
183
+ host = raw.rsplit(":", 1)[0] if ":" in raw else raw
184
+ host = host.lower()
185
+ if host in ("localhost", "localhost.localdomain"):
186
+ return True
187
+ try:
188
+ return ipaddress.ip_address(host).is_loopback
189
+ except ValueError:
190
+ return False # any other name → rebinding, refuse
191
+
192
+ def _same_origin(self) -> bool:
193
+ """Reject a cross-site Origin outright. Belt to the token's braces, and it also
194
+ covers the no-preflight form-POST shape a CSRF page would use.
195
+
196
+ Only meaningful once `_loopback_host` has passed — the expected origin is built
197
+ from the request's Host, so an unpinned Host makes this check self-satisfying.
198
+ """
199
+ origin = self.headers.get("Origin")
200
+ if not origin:
201
+ return True # non-browser client (curl, the desktop shell) — token still required
202
+ want = f"http://{self.headers.get('Host', '')}"
203
+ return origin == want or origin == want.replace("http://", "https://")
204
+
205
+ def _kill_authorized(self) -> bool:
206
+ supplied = self.headers.get(_KILL_TOKEN_HEADER, "")
207
+ return bool(self.kill_token) and hmac.compare_digest(supplied, self.kill_token)
208
+
209
+ def do_GET(self) -> None: # noqa: N802
210
+ # Before ANYTHING is served — the page carries the kill token, and /status
211
+ # carries earnings, load and the trust label.
212
+ if not self._loopback_host():
213
+ self._send(403, b"bad host", "text/plain")
214
+ return
215
+ if self.path.startswith("/status"):
216
+ self._send(200, json.dumps(self.stats_fn()).encode(), "application/json")
217
+ elif self.path in ("/", "/index.html"):
218
+ page = _PAGE.replace("__KILL_TOKEN__", json.dumps(self.kill_token))
219
+ self._send(200, page.encode(), "text/html; charset=utf-8")
220
+ else:
221
+ self._send(404, b"not found", "text/plain")
222
+
223
+ def do_POST(self) -> None: # noqa: N802
224
+ if not self._loopback_host():
225
+ self._send(403, b"bad host", "text/plain")
226
+ return
227
+ if not self.path.startswith("/kill"):
228
+ self._send(404, b"not found", "text/plain")
229
+ return
230
+ if not self._same_origin() or not self._kill_authorized():
231
+ self._send(403, b'{"ok":false,"error":"kill token required"}', "application/json")
232
+ return
233
+ # STOP FIRST, THEN SAY SO. This used to answer `{"ok":true}` and call `kill_fn()` afterwards,
234
+ # which is the wrong order for the one control where it matters most: the owner was told their
235
+ # node had stopped sharing BEFORE anything had stopped, and if the stop then raised they were
236
+ # told it succeeded anyway. A kill switch that reports success it has not achieved is worse
237
+ # than one that fails loudly.
238
+ #
239
+ # It was also a race the tests saw: the client could read the 200 and check the flag before
240
+ # the server thread reached the call. That flakiness was the defect showing itself.
241
+ try:
242
+ self.kill_fn()
243
+ except Exception as e: # noqa: BLE001 - the owner must hear about this
244
+ self._send(500, json.dumps(
245
+ {"ok": False, "error": f"could not stop sharing: {type(e).__name__}"}).encode(),
246
+ "application/json")
247
+ return
248
+ self._send(200, b'{"ok":true}', "application/json")
249
+
250
+ def log_message(self, *args) -> None: # keep the console quiet
251
+ pass
252
+
253
+
254
+ def start_gui(stats_fn: Callable[[], dict], kill_fn: Callable[[], None], port: int,
255
+ host: str = "127.0.0.1") -> str:
256
+ """Serve the owner's dashboard on loopback and return its URL.
257
+
258
+ The kill token is minted per process and rendered into the page, never put in the
259
+ URL (URLs leak through history, referrers and shoulders).
260
+ """
261
+ token = secrets.token_urlsafe(24)
262
+ handler = type("Handler", (_Handler,), {
263
+ "stats_fn": staticmethod(stats_fn),
264
+ "kill_fn": staticmethod(kill_fn),
265
+ "kill_token": token,
266
+ })
267
+ server = ThreadingHTTPServer((host, port), handler)
268
+ actual_port = server.server_address[1] # resolves port 0 to the real one
269
+ threading.Thread(target=server.serve_forever, daemon=True).start()
270
+ return f"http://{host}:{actual_port}"
frutebox/imagegen.py ADDED
@@ -0,0 +1,152 @@
1
+ """Image generation — runs on a GPU node. Diffusers/torch are heavy and only present
2
+ on an image-capable node, so they're imported lazily; on any other machine `available()`
3
+ is False and the gateway simply never routes image jobs here.
4
+
5
+ WHICH model runs is not decided here. `mediaserving.resolve_media_model("image", ...)` answers
6
+ that from the file-09 registry, licence-gated and sized to THIS card's VRAM — so a 12 GB
7
+ contributor GPU runs the 10 GB model locally and free (plan 23 §B3) instead of failing to load
8
+ the 24 GB primary. This module used to hardcode `stabilityai/stable-diffusion-xl-base-1.0` as
9
+ `IMAGE_MODEL`'s default and say "Flux etc. can be swapped via env", which was an invitation to
10
+ put a non-commercial model on a GPU: FLUX.1 [dev] is exactly one of the five exclusions in plan
11
+ 23 §A. The env override still exists and is now VALIDATED rather than trusted.
12
+
13
+ `AutoPipelineForText2Image`, not a model-specific class: the resolver may legitimately return
14
+ Qwen-Image, SDXL or a later primary, and each needs a different pipeline class. The Auto classes
15
+ read the repo's `model_index.json` and pick it, which is why plan 23 §B1 names `diffusers` as
16
+ the core serving path rather than one model's pipeline.
17
+
18
+ Defence in depth against NSFW (on top of the safety INPUT/OUTPUT gates in `safety.py`): a
19
+ strong default NEGATIVE prompt steers the model away from nudity/sexual content at
20
+ generation time. The authoritative blocks remain the gates — this just makes the generator
21
+ itself disinclined to produce it.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import io
26
+
27
+ from . import mediaserving
28
+
29
+ # Baked-in safety negative prompt — always applied, appended to any caller negative.
30
+ _SAFETY_NEGATIVE = "nsfw, nude, nudity, naked, sexual, explicit, pornographic, erotic, gore"
31
+
32
+ _pipe = None
33
+ _img2img = None
34
+ _loaded_ref = ""
35
+
36
+
37
+ def device_vram_gb() -> float:
38
+ """Usable VRAM on this node's first GPU, 0.0 where there is none.
39
+
40
+ This is what makes §B3's local-first path real: the resolver needs to know what actually
41
+ fits here, or "local-first" means "load the primary and crash".
42
+ """
43
+ try:
44
+ import torch # type: ignore
45
+ if not torch.cuda.is_available():
46
+ return 0.0
47
+ return torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
48
+ except Exception:
49
+ return 0.0
50
+
51
+
52
+ def available() -> bool:
53
+ """True iff this node can actually generate images (diffusers + torch + CUDA + a model
54
+ it is allowed to run). The last clause matters: a node with a working GPU and no
55
+ commercial-safe model that fits it cannot serve image jobs, and advertising the modality
56
+ would have the gateway route work it must then refuse."""
57
+ try:
58
+ import torch # type: ignore
59
+ import diffusers # type: ignore # noqa: F401
60
+ if not torch.cuda.is_available():
61
+ return False
62
+ except Exception:
63
+ return False
64
+ try:
65
+ resolved_model()
66
+ return True
67
+ except mediaserving.MediaUnavailable:
68
+ return False
69
+
70
+
71
+ def resolved_model(*, node_region: str = "", user_region: str = ""):
72
+ """The registry-declared, licence-gated image model this node will run, sized to its card.
73
+
74
+ Raises `mediaserving.MediaUnavailable` naming every candidate and why it was rejected.
75
+ """
76
+ return mediaserving.resolve_media_model(
77
+ "image",
78
+ node_region=node_region, user_region=user_region,
79
+ override=mediaserving.model_override("IMAGE_MODEL"),
80
+ max_vram_gb=device_vram_gb(),
81
+ )
82
+
83
+
84
+ def _load(spec):
85
+ """Load (once) the text-to-image pipeline for `spec`. Reloads if the resolver's answer
86
+ changed — an operator flipping IMAGE_MODEL must not keep getting the old weights."""
87
+ global _pipe, _img2img, _loaded_ref
88
+ if _pipe is None or _loaded_ref != spec.ref:
89
+ import torch # type: ignore
90
+ from diffusers import AutoPipelineForText2Image # type: ignore
91
+ _pipe = AutoPipelineForText2Image.from_pretrained(
92
+ spec.repo, revision=spec.revision, torch_dtype=torch.float16).to("cuda")
93
+ _img2img = None # the cached img2img shared the OLD pipeline's components
94
+ _loaded_ref = spec.ref
95
+ return _pipe
96
+
97
+
98
+ def _load_img2img(spec):
99
+ """The img2img pipeline, sharing the text pipeline's already-loaded components.
100
+
101
+ `from_pipe` reuses the same UNet/VAE/text encoders rather than loading a second ~7 GB copy —
102
+ conditioning is the SAME model applied differently, not a different model.
103
+ """
104
+ global _img2img
105
+ base = _load(spec)
106
+ if _img2img is None:
107
+ from diffusers import AutoPipelineForImage2Image # type: ignore
108
+ _img2img = AutoPipelineForImage2Image.from_pipe(base)
109
+ return _img2img
110
+
111
+
112
+ def generate_image(prompt: str, *, steps: int = 30, width: int = 1024, height: int = 1024,
113
+ negative: str = "", init: bytes = b"", strength: float = 0.4,
114
+ node_region: str = "", user_region: str = "") -> bytes:
115
+ """Generate a PNG (bytes) from a text prompt. Raises RuntimeError where no allowed model
116
+ can run here; the safety negative prompt is always applied.
117
+
118
+ ENHANCE RATHER THAN RE-ROLL (file 03-E2). When `init` carries the user's local sketch this
119
+ runs img2img at `strength` (0.4 by default, the plan's decision) instead of generating from
120
+ the prompt alone. That distinction is the whole paid feature: the user has already SEEN the
121
+ local sketch and chose to improve THAT image. Regenerating from the prompt would hand back a
122
+ different composition with a different subject placement — not an enhancement of the thing
123
+ they paid to enhance, just a second, unrelated picture.
124
+ """
125
+ if not prompt or not prompt.strip():
126
+ raise ValueError("empty image prompt")
127
+ try:
128
+ import torch # type: ignore
129
+ import diffusers # type: ignore # noqa: F401
130
+ if not torch.cuda.is_available():
131
+ raise RuntimeError("image generation is not available on this node: no CUDA GPU")
132
+ except ImportError as e:
133
+ raise RuntimeError(f"image generation is not available on this node: {e}") from e
134
+ spec = resolved_model(node_region=node_region, user_region=user_region)
135
+
136
+ neg = f"{negative}, {_SAFETY_NEGATIVE}".strip(", ") if negative else _SAFETY_NEGATIVE
137
+ steps = max(1, min(steps, 60))
138
+
139
+ if init:
140
+ from PIL import Image # type: ignore
141
+ # Strength is clamped, not trusted: 0 would return the sketch untouched (the user paid for
142
+ # nothing) and 1 discards it entirely (a re-roll wearing the name "enhance").
143
+ s = min(0.95, max(0.05, float(strength)))
144
+ sketch = Image.open(io.BytesIO(init)).convert("RGB")
145
+ image = _load_img2img(spec)(prompt=prompt, negative_prompt=neg, image=sketch,
146
+ strength=s, num_inference_steps=steps).images[0]
147
+ else:
148
+ image = _load(spec)(prompt=prompt, negative_prompt=neg, num_inference_steps=steps,
149
+ width=width, height=height).images[0]
150
+ buf = io.BytesIO()
151
+ image.save(buf, format="PNG")
152
+ return buf.getvalue()