Skip to content

Commit 8f28296

Browse files
committed
Allow validated master HTML/CSS dashboard edits with automatic live refresh
1 parent 955b2c9 commit 8f28296

4 files changed

Lines changed: 278 additions & 4 deletions

File tree

‎V100_AUDIT.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ Serving a dashboard on the operator host does not relax the private VM's network
99
or filesystem isolation. Reachability from another LAN device still needs an
1010
on-device check.
1111

12+
The master can edit `/workspace/dashboard/index.html` through the existing chat
13+
`sandbox` action. The viewer pulls only this fixed guest file, validates bounded
14+
HTML/CSS and component IDs, preserves prior versions and retains the last valid
15+
layout on failure. The data renderer remains host-owned and CSP permits only its
16+
exact script hash. The user preferences carry this presentation permission
17+
without changing the long-term goal or existing directives. Status is saved in
18+
`research/dashboard/layout-status.json`. Browser polling refreshes data and
19+
reloads a changed validated layout automatically. This is presentation editing,
20+
not permission to change financial evidence or run arbitrary host commands.
21+
1222
This inventory separates source implementation from configuration, execution and
1323
demonstrated improvement. Passing controller tests does not certify GPU stability,
1424
profitable trading or a successful model promotion. It is not a declaration that

‎tests/test_v100_dashboard.py‎

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import contextlib
12
import importlib.util
3+
import io
24
import json
35
import threading
46
from pathlib import Path
@@ -23,7 +25,8 @@ def test_dashboard_rejects_unspecified_public_and_non_ipv4_binds(address):
2325

2426
def test_dashboard_private_bind_and_readonly_http_routes(tmp_path):
2527
assert dashboard.validate_bind("192.168.0.68") == "192.168.0.68"
26-
state = SimpleNamespace(root=tmp_path, data={"mission": {"running": True}})
28+
state = dashboard.DashboardState(tmp_path, lambda root: {})
29+
state.data = {"mission": {"running": True}}
2730
with dashboard.DashboardServer(("127.0.0.1", 0), state) as server:
2831
thread = threading.Thread(target=server.serve_forever)
2932
thread.start()
@@ -34,6 +37,10 @@ def test_dashboard_private_bind_and_readonly_http_routes(tmp_path):
3437
assert response.headers["Cache-Control"] == "no-store"
3538
with urlopen(origin, timeout=3) as response:
3639
assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"]
40+
assert "script-src 'sha256-" in response.headers["Content-Security-Policy"]
41+
assert (
42+
"script-src 'unsafe-inline'" not in response.headers["Content-Security-Policy"]
43+
)
3744
assert b"/api/status" in response.read()
3845
for path in ("/env.sh", "/paper/%2e%2e/env.sh", "/api/stop"):
3946
with pytest.raises(HTTPError) as error:
@@ -100,3 +107,80 @@ def unavailable(command, **kwargs):
100107
assert state.data["report"] == {}
101108
assert state.data["gpu"] == {}
102109
assert len(state.data["errors"]) == 2
110+
111+
112+
@pytest.mark.parametrize(
113+
"addition",
114+
[
115+
"<script>alert(1)</script>",
116+
'<div onclick="alert(1)">x</div>',
117+
'<iframe src="http://192.168.0.1"></iframe>',
118+
'<style>@import "https://example.com/leak";</style>',
119+
"<style>body{background:u/**/rl(https://example.com)}</style>",
120+
'<meta http-equiv="refresh" content="0;url=https://example.com">',
121+
'<a href="https://example.com">outside</a>',
122+
],
123+
)
124+
def test_master_layout_refuses_active_content_and_external_resources(addition):
125+
content = dashboard.BASE_TEMPLATE.replace("</html>", addition + "</html>")
126+
with pytest.raises(ValueError):
127+
dashboard.validate_template(content)
128+
129+
130+
def test_master_layout_preserves_data_components_and_fixed_renderer():
131+
content = dashboard.BASE_TEMPLATE.replace("background:#101721", "background:#172b35")
132+
identity = dashboard.validate_template(content)
133+
page = dashboard.render_template(content, identity)
134+
assert "background:#172b35" in page
135+
assert page.count("<script>") == 1
136+
assert f"const layoutVersion='{identity}'" in page
137+
assert "setInterval(refresh,5000)" in page
138+
with pytest.raises(ValueError, match="component IDs"):
139+
dashboard.validate_template(content.replace('id="cards"', 'id="other"'))
140+
141+
142+
def test_master_invalid_edit_keeps_previous_layout_and_survives_restart(tmp_path, monkeypatch):
143+
state = dashboard.DashboardState(tmp_path, lambda root: {})
144+
changed = dashboard.BASE_TEMPLATE.replace("V100 - postęp misji", "Mój pulpit V100")
145+
monkeypatch.setattr(dashboard, "guest_layout", lambda root: changed)
146+
state.sync_layout()
147+
accepted = state.page
148+
identity = state.layout["active_sha256"]
149+
assert "Mój pulpit V100" in accepted
150+
monkeypatch.setattr(dashboard, "guest_layout", lambda root: "<script>bad()</script>")
151+
state.sync_layout()
152+
assert state.page == accepted
153+
assert state.layout["active_sha256"] == identity
154+
assert state.layout["error"]
155+
restarted = dashboard.DashboardState(tmp_path, lambda root: {})
156+
assert restarted.page == accepted
157+
assert len(list((tmp_path / "research/dashboard/layouts").glob("*.html"))) == 2
158+
159+
160+
def test_master_oversized_layout_is_not_published():
161+
with pytest.raises(ValueError, match="UTF-8 bytes"):
162+
dashboard.validate_template(dashboard.BASE_TEMPLATE + (" " * 262144))
163+
164+
165+
def test_guest_layout_seed_preserves_master_edits_and_bounds_transfer(tmp_path, monkeypatch):
166+
from rlm.v100 import desktop
167+
168+
path = tmp_path / "guest/dashboard/index.html"
169+
monkeypatch.setattr(dashboard, "GUEST_TEMPLATE", str(path))
170+
171+
def guest_run(root, script, **kwargs):
172+
assert len(script) <= 16000
173+
assert kwargs == {"seconds": 8, "output_limit": 360000}
174+
output = io.StringIO()
175+
with contextlib.redirect_stdout(output):
176+
exec(script.split("\n", 1)[1].rsplit("V100_LAYOUT", 1)[0], {})
177+
return {"exit_code": 0, "stdout": output.getvalue(), "stderr": ""}
178+
179+
monkeypatch.setattr(desktop, "run", guest_run)
180+
assert dashboard.guest_layout(tmp_path) == dashboard.BASE_TEMPLATE
181+
changed = dashboard.BASE_TEMPLATE.replace("V100 - postęp misji", "Nowy wygląd")
182+
path.write_text(changed)
183+
assert dashboard.guest_layout(tmp_path) == changed
184+
path.write_bytes(b"x" * 262145)
185+
with pytest.raises(ValueError, match="256 KiB"):
186+
dashboard.guest_layout(tmp_path)

‎tools/serve-v100-dashboard.py‎

Lines changed: 168 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"""Read-only LAN dashboard for an existing V100 installation; no model reload."""
33

44
import argparse
5+
import base64
6+
import hashlib
57
import ipaddress
68
import json
79
import math
@@ -10,6 +12,7 @@
1012
import sys
1113
import threading
1214
import time
15+
from html.parser import HTMLParser
1316
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1417
from pathlib import Path
1518
from urllib.parse import unquote, urlsplit
@@ -28,7 +31,7 @@
2831
:root{color-scheme:dark}body{font:15px system-ui;background:#101721;color:#e9eef6;margin:0;padding:24px;max-width:1200px;margin:auto}h1{font-size:26px}h2{font-size:19px}small,.muted{color:#adbdd1}a{color:#9ac5ff}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.card,section{background:#192433;border:1px solid #304259;border-radius:12px;padding:18px;margin:14px 0}.card{margin:0}.value{display:block;font-size:22px;margin-top:8px;overflow-wrap:anywhere}progress{width:100%;height:24px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;max-height:420px;overflow:auto}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:10px;border-bottom:1px solid #304259}svg{width:100%;max-height:250px}#warning{color:#ffbf86}button{padding:8px 14px;background:#294464;border:0;color:white;border-radius:6px;cursor:pointer}
2932
</style>
3033
<h1>V100 - postęp misji</h1><p class="muted">Podgląd odczytu. Paper P&amp;L i backtesty nie są realnym dochodem.</p>
31-
<p id="fresh">Ładowanie danych...</p><p id="warning"></p><button id="reload">Odśwież widok</button>
34+
<p id="fresh">Ładowanie danych...</p><p id="warning"></p><p id="layout" class="muted"></p><button id="reload">Odśwież widok</button>
3235
<div id="cards" class="grid"></div>
3336
<section><h2>Testy bieżącego przebiegu</h2><p id="evaluation"></p><progress id="progress" max="1" value="0"></progress><p id="case" class="muted"></p></section>
3437
<section><h2>Ostatnie backtesty - eksploracyjne</h2><p class="muted">Wynik netto prób historycznych. Założenia kosztów nie są potwierdzeniem dostępnych opłat ani przewagi strategii.</p><svg id="chart" viewBox="0 0 800 220" role="img" aria-label="Wyniki ostatnich backtestów"></svg><table><thead><tr><th>Próba</th><th>Wynik netto</th><th>Obsunięcie</th><th>Wykonania</th></tr></thead><tbody id="backtests"></tbody></table></section>
@@ -39,9 +42,11 @@
3942
<details><summary>Dowody treningu i ostatnie błędy</summary><pre id="evidence"></pre></details>
4043
<script>
4144
const $=id=>document.getElementById(id), fmt=x=>x===null||x===undefined?'brak potwierdzonych danych':String(x), pct=x=>typeof x==='number'&&Number.isFinite(x)?(x*100).toFixed(2)+'%':'brak danych';
45+
const layoutVersion='__LAYOUT_SHA__';
4246
function card(label,value){const node=document.createElement('div');node.className='card';const title=document.createElement('small');title.textContent=label;const strong=document.createElement('strong');strong.className='value';strong.textContent=fmt(value);node.append(title,strong);$('cards').append(node)}
4347
function svg(tag,attrs,text){let node=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [key,val] of Object.entries(attrs))node.setAttribute(key,val);if(text!==undefined)node.textContent=text;return node}
4448
function render(data){const m=data.mission||{},r=data.report||{},ev=m.evaluation||{},learn=m.learning||{},gpu=data.gpu||{};
49+
if(data.layout?.active_sha256&&layoutVersion!==data.layout.active_sha256){location.reload();return}$('layout').textContent='HTML mastera: /workspace/dashboard/index.html | '+(data.layout?.state||'oczekiwanie')+(data.layout?.error?' | '+data.layout.error:'');
4550
$('fresh').textContent='Zebrano: '+new Date(data.collected_at*1000).toLocaleString()+' | Raport zbierany co 20 s | Przebieg: '+fmt(m.run);
4651
$('warning').textContent=(data.errors||[]).join('\n');$('cards').replaceChildren();card('Misja',m.running?'działa':'zatrzymana');card('Faza',m.state?.phase);card('Cykle uczenia',learn.completed_cycles);card('Zaakceptowane aktualizacje wag',r.accepted_weight_updates_this_run);card('Potwierdzone kroki optymalizatora',r.mission_evidence?.optimizer_updates_observed);card('Kontekst',m.state?.context_window);card('GPU - wykorzystanie',gpu.utilization===undefined?null:gpu.utilization+'%');card('VRAM zajęty / razem',gpu.memory_used===undefined?null:gpu.memory_used+' / '+gpu.memory_total+' MiB');card('GPU - temperatura / moc',gpu.temperature===undefined?null:gpu.temperature+' °C / '+gpu.power+' W');
4752
$('evaluation').textContent=ev.total?`${ev.completed}/${ev.total} ukończonych, ${ev.passed} zaliczonych (${ev.state})`:'Brak bieżącego licznika ewaluacji';$('progress').max=ev.total||1;$('progress').value=ev.completed||0;$('case').textContent=ev.current_case?('Obecny przypadek: '+ev.current_case):'';
@@ -54,6 +59,110 @@
5459
</script></html>"""
5560

5661

62+
APP_SCRIPT = PAGE.split("<script>", 1)[1].split("</script>", 1)[0]
63+
BASE_TEMPLATE = PAGE.split("<script>", 1)[0] + "</html>"
64+
GUEST_TEMPLATE = "/workspace/dashboard/index.html"
65+
REQUIRED_IDS = set(re.findall(r"\$\('([a-z]+)'\)", APP_SCRIPT))
66+
67+
68+
def validate_css(value):
69+
value = re.sub(r"/\*.*?\*/", "", value, flags=re.S).lower()
70+
if "\\" in value or re.search(r"@import|url\s*\(|expression\s*\(|behavior\s*:", value):
71+
raise ValueError("CSS must not load external resources or execute code")
72+
73+
74+
class LayoutValidator(HTMLParser):
75+
allowed_tags = set(
76+
"html head body title meta style h1 h2 h3 h4 h5 h6 p div span section article header footer main nav aside strong small em b i br hr ul ol li a button pre code details summary progress table thead tbody tfoot tr th td svg g rect line polyline polygon circle path text defs lineargradient stop".split()
77+
)
78+
79+
def __init__(self):
80+
super().__init__(convert_charrefs=True)
81+
self.ids = set()
82+
self.in_style = False
83+
self.styles = []
84+
85+
def handle_starttag(self, tag, attrs):
86+
if tag not in self.allowed_tags:
87+
raise ValueError("Unsupported layout element: " + tag)
88+
if tag == "style":
89+
self.in_style = True
90+
for name, value in attrs:
91+
value = value or ""
92+
if name.startswith("on") or name in (
93+
"src",
94+
"srcdoc",
95+
"http-equiv",
96+
"action",
97+
"formaction",
98+
"xlink:href",
99+
):
100+
raise ValueError("Active or external layout attribute is not allowed")
101+
if name == "href" and not (value.startswith("#") or value.startswith("/paper/")):
102+
raise ValueError("Layout links must target existing paper reports or anchors")
103+
if name == "href" and any(char in value for char in ("\\", "\n", "\r")):
104+
raise ValueError("Invalid layout link")
105+
if name == "style":
106+
validate_css(value)
107+
if name == "id":
108+
if value in self.ids:
109+
raise ValueError("Duplicate layout ID: " + value)
110+
self.ids.add(value)
111+
112+
def handle_endtag(self, tag):
113+
if tag not in self.allowed_tags:
114+
raise ValueError("Unsupported closing element")
115+
if tag == "style":
116+
self.in_style = False
117+
118+
def handle_data(self, data):
119+
if self.in_style:
120+
self.styles.append(data)
121+
122+
123+
def validate_template(content):
124+
if not isinstance(content, str) or not 1 <= len(content.encode()) <= 256 * 1024:
125+
raise ValueError("Layout must contain 1..262144 UTF-8 bytes")
126+
parser = LayoutValidator()
127+
parser.feed(content)
128+
parser.close()
129+
if parser.in_style or content.count("<!--") != content.count("-->"):
130+
raise ValueError("Close style elements and HTML comments")
131+
validate_css("".join(parser.styles))
132+
missing = REQUIRED_IDS - parser.ids
133+
if missing:
134+
raise ValueError("Keep dashboard component IDs: " + ", ".join(sorted(missing)))
135+
if not re.search(r"</html\s*>\s*\Z", content, re.I):
136+
raise ValueError("Keep the closing html element")
137+
return hashlib.sha256(content.encode()).hexdigest()
138+
139+
140+
def render_template(content, identity):
141+
script = "<script>" + APP_SCRIPT.replace("__LAYOUT_SHA__", identity) + "</script>"
142+
return re.sub(
143+
r"</html\s*>\s*\Z", lambda match: script + match.group(), content, count=1, flags=re.I
144+
)
145+
146+
147+
def guest_layout(root):
148+
from rlm.v100.desktop import run
149+
150+
seed = base64.b64encode(BASE_TEMPLATE.encode()).decode()
151+
program = (
152+
"import base64,json\nfrom pathlib import Path\n"
153+
f"p=Path({GUEST_TEMPLATE!r})\np.parent.mkdir(parents=True,exist_ok=True)\n"
154+
f"if not p.exists(): p.write_bytes(base64.b64decode({seed!r}))\n"
155+
"if p.stat().st_size>262144: raise ValueError('Layout exceeds 256 KiB')\n"
156+
"print(json.dumps({'html_base64':base64.b64encode(p.read_bytes()).decode()}))\n"
157+
)
158+
script = "python3 - <<'V100_LAYOUT'\n" + program + "V100_LAYOUT\n"
159+
result = run(root, script, seconds=8, output_limit=360000)
160+
if result["exit_code"]:
161+
raise RuntimeError(result["stderr"][:300] or "Guest layout unavailable")
162+
value = json.loads(result["stdout"])
163+
return base64.b64decode(value["html_base64"], validate=True).decode()
164+
165+
57166
def validate_bind(address):
58167
ip = ipaddress.ip_address(address)
59168
if (
@@ -102,6 +211,57 @@ def __init__(self, root, mission_reader):
102211
self.mission_reader = mission_reader
103212
self.data = {"collected_at": time.time(), "errors": ["Pierwsze zbieranie danych trwa"]}
104213
self.stop = threading.Event()
214+
self.layout = {"guest_file": GUEST_TEMPLATE, "state": "waiting for guest template"}
215+
self.page = render_template(BASE_TEMPLATE, validate_template(BASE_TEMPLATE))
216+
saved = self.root / "research/dashboard/index.html"
217+
content, failure = BASE_TEMPLATE, None
218+
try:
219+
if saved.exists():
220+
if saved.stat().st_size > 256 * 1024:
221+
raise ValueError("Saved layout exceeds 256 KiB")
222+
content = saved.read_text()
223+
validate_template(content)
224+
except (OSError, ValueError) as error:
225+
content, failure = BASE_TEMPLATE, str(error)[:300]
226+
self.publish_layout(content)
227+
if failure:
228+
self.layout["error"] = failure
229+
230+
def publish_layout(self, content):
231+
identity = validate_template(content)
232+
folder = self.root / "research/dashboard"
233+
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
234+
history = folder / "layouts"
235+
history.mkdir(exist_ok=True, mode=0o700)
236+
version = history / (identity + ".html")
237+
if not version.exists():
238+
version.write_text(content)
239+
if self.layout.get("active_sha256") != identity:
240+
temporary = folder / "index.html.tmp"
241+
temporary.write_text(content)
242+
temporary.replace(folder / "index.html")
243+
self.page = render_template(content, identity)
244+
self.layout = {
245+
"guest_file": GUEST_TEMPLATE,
246+
"state": "validated layout active",
247+
"active_sha256": identity,
248+
"published_at": time.time(),
249+
}
250+
251+
def sync_layout(self):
252+
try:
253+
self.publish_layout(guest_layout(self.root))
254+
except (OSError, ValueError, RuntimeError, KeyError, TypeError) as error:
255+
self.layout = {
256+
**self.layout,
257+
"state": "previous validated layout retained",
258+
"error": str(error)[:300],
259+
}
260+
folder = self.root / "research/dashboard"
261+
temporary = folder / "layout-status.json.tmp"
262+
temporary.write_text(json.dumps(self.layout, ensure_ascii=False))
263+
temporary.replace(folder / "layout-status.json")
264+
self.data = {**self.data, "layout": self.layout}
105265

106266
def refresh(self):
107267
errors, report, gpu = [], {}, {}
@@ -186,6 +346,7 @@ def loop(self):
186346
while not self.stop.is_set():
187347
try:
188348
self.refresh()
349+
self.sync_layout()
189350
except (OSError, ValueError, RuntimeError) as error:
190351
self.data = {"collected_at": time.time(), "errors": [str(error)[:600]]}
191352
self.stop.wait(20)
@@ -228,7 +389,7 @@ def do_GET(self): # noqa: N802 - standard HTTP callback
228389
route = urlsplit(self.path).path
229390
try:
230391
if route == "/":
231-
body, mime = PAGE.encode(), "text/html; charset=utf-8"
392+
body, mime = self.server.state.page.encode(), "text/html; charset=utf-8"
232393
elif route == "/api/status":
233394
body, mime = (
234395
json.dumps(
@@ -249,7 +410,11 @@ def do_GET(self): # noqa: N802 - standard HTTP callback
249410
self.send_header("X-Content-Type-Options", "nosniff")
250411
self.send_header(
251412
"Content-Security-Policy",
252-
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'"
413+
"default-src 'none'; script-src 'sha256-"
414+
+ base64.b64encode(
415+
hashlib.sha256(body.split(b"<script>", 1)[1].split(b"</script>", 1)[0]).digest()
416+
).decode()
417+
+ "'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
253418
if route == "/"
254419
else "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
255420
)

0 commit comments

Comments
 (0)