Automatischer Push: 6 Datei(en) geändert
- .vault-tools/plan-kopie/LIESMICH.md - .vault-tools/plan-kopie/rot_nachweis.py - .vault-tools/trading-plattform-pruefung/LIESMICH.md - .vault-tools/trading-plattform-pruefung/browser_r.py - .vault-tools/trading-plattform-pruefung/start_probe_w.py - .vault-tools/trading-plattform-pruefung/start_probe_zugang.py Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
593acdd122
commit
d7ffc9a5b5
6 files changed
+233
-5
No files matched your search
@@ -79,6 +79,12 @@ Koordinator-Werkzeuge, nicht fürs Repo. Sie starten **nie** den echten Broker:
|
||||
| `killswitch_simulation.py` | der Killswitch im Zeitraffer, **ohne Netz, ohne das echte Konto und ohne das echte Register** — die echte Funktion `_generalprobe` mit den Kerzen eines Tages (JSON aus `hole_kerzen.py`), verdrahtet wie `generalprobe_echtlauf.py`. Drei Szenarien: `hand` (fortsetzen, anhalten, fortsetzen, glattstellen, freigeben — gehandelt wird nur im Zustand `aktiv`, beim Anhalten bleibt die Position stehen, nach dem Glattstellen ist sie null und der Runner gesperrt), `sturz` (alle Kerzen ab 11:00 Uhr New Yorker Zeit zehn Prozent tiefer — der Runner reißt die Tagesverlustgrenze selbst, stellt glatt, sperrt, meldet „Grenze verletzt", und sperrt nach einem „Freigeben" im selben Takt wieder, ohne neue Order) und `neustart` (das Dashboard startet mitten am Tag neu — Pause mit stehender Position, Pausenmeldung mit dem Auslöser `neustart`, und die Latenzanzeige zählt den Neustart nicht als Klick). Der Kopfkommentar nennt je Prüfung die Herkunft der Erwartung und die Grenzen: Gefüllt wird gespielt, der Klick geht nicht über HTTP, der Watchdog wird nicht gezielt ausgelöst. **Anlass:** Die Killswitch-Übung des Projekts stellt echte Orders und schreibt ins gemeinsame Register — sie darf nicht laufen, solange der Paper-Runner läuft. Gemessen am 2026-09-21 gegen den Betriebscode (0.20.0) mit den Kerzen des 2026-09-18 bei 1 s je Marktminute: je Szenario 431 s, Exit 0, 389 Takte, kein Befund und kein Hinweis; Reaktion des Runners auf einen Klick 0,85 bis 0,99 s (neun Messungen) | in WSL: `python killswitch_simulation.py <hand\|sturz\|neustart> <code-baum> <echter-projektordner> <kerzen.json> <wegwerf-ordner> [tempo]` |
|
||||
| `killswitch_simulation_mutanten.py` | der Regressionsnachweis dazu: kopiert den Code-Baum, fährt zuerst die Grundlinie, macht die Kopie dann an sechs Stellen einzeln kaputt (Runner übergeht den Killswitch; Glattstellen schließt nicht; Tagesverlustgrenze greift nicht; jeder Auslöser zählt als Klick; keine Pausenmeldung beim Neustart; Tagesverlust hält nur an) und erwartet je Mutant Exit 2. Ein Muster, das nicht genau einmal trifft, ist ein Befund des Treibers. In zwei Teilen, je unter zehn Minuten. Gemessen am 2026-09-21 gegen den Betriebscode: Grundlinie und Gegenprobe in allen drei Szenarien ohne Befund, alle sechs Mutanten gesehen — drei davon an genau einer Prüfung (die Position nach dem Glattstellen, die Zahl der Klick-Messungen, die fehlende Pausenmeldung). **Der erste Lauf der Grundlinie fand dabei einen Fehler der Prüfung, nicht des Runners:** Im Schnelltempo fiel ein Klick mitten in einen Takt; dessen Taktzeile trug schon `gesperrt`, aber noch die Phase `handelt`, ohne Entscheidung und ohne Order. Seitdem ist alles ab dem zweiten Takt eines nicht aktiven Abschnitts verbindlich, der erste wird als Hinweis gedruckt | in WSL: `python killswitch_simulation_mutanten.py <code-baum> <echter-projektordner> <kerzen.json> <arbeitsordner> <1\|2>` |
|
||||
|
||||
## Red Button (2026-09-22, Paket R)
|
||||
|
||||
| Datei | Was sie tut | Aufruf |
|
||||
|---|---|---|
|
||||
| `browser_r.py` | Browserlauf zum Red Button mit virtuellem Sicherheitsschlüssel gegen eine Probe-Instanz (`start_probe_w.py` aus einer Kopie ohne Geheimnisdatei, mit gebautem `dashboard/dist`): anmelden mit Code, Schlüssel registrieren, unter `127.0.0.1` den Dialog öffnen (Hinweis „nur unter http://localhost:…", kein Wortfeld, gesperrter Knopf, kein POST), unter `localhost` den Red Button drücken, Grund eintragen, Stick (virtuell) berühren — beginn und Abschluss 200, Zähler des Authenticators steigt, genau ein Registerereignis `glattstellen` mit Auslöser `pascal`, Kopfzeile gesperrt; Screenshots. Importiert `totp`, `schritt` und `melde_mit_code_an` aus `browser_w.py`. **Reihenfolge ist Absicht:** erst `127.0.0.1`, solange der Killswitch noch bedienbar ist — nach dem Notaus ist er gesperrt. Gemessen am 2026-09-22: 15 von 16, der eine Befund war ein zu strenger Check des Skripts (berichtigt). Nicht gemessen: eine abgelehnte Signatur (der virtuelle Authenticator signiert immer gültig), das Wort ohne Schlüssel, die Protokollbremse — das deckt `tests/test_dashboard_red_button.py` | unter Windows: `python -X utf8 browser_r.py <port> <ablage-der-probe> <ordner-fuer-screenshots>` |
|
||||
|
||||
Das Einrichten selbst (`deploy/dienste-einrichten.sh` unter `sudo -S`, `deploy/windows/wsl-wachhalten.ps1 -Aktion einrichten`) und das Eintragen des ntfy-Themas liefen aus dem Scratchpad der Sitzung; die Befehle stehen in der Administrator-Anleitung des Projekts, Abschnitt „Produktivbetrieb als Dienst".
|
||||
|
||||
Die Compose-Probe (Bauen aus dem echten Baum mit verankerten Ausschlüssen, Start, Healthcheck, Angriffsfälle, Runner-Probe im Container, Aufräumen) lag als `compose_probe_echt.sh` im Scratchpad der Sitzung und braucht `sudo -S` mit dem WSL-Passwort aus `$WSL_PW`; sie ist deshalb nicht hier abgelegt. Muster: `tar --exclude=./data` — ohne Anker fällt auch `trading/data` weg.
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Browserlauf zu Paket R: der Red Button mit virtuellem Sicherheitsschluessel gegen eine Probe-Instanz.
|
||||
|
||||
Koordinator-Pruefcode, nicht Teil des Repos. Laeuft unter Windows mit Playwright (Chromium) und dem
|
||||
virtuellen Authenticator des DevTools-Protokolls, wie browser_w.py. Ablauf: mit Passwort und Code
|
||||
anmelden, in "Zugang" einen Schluessel registrieren, zurueck zur Uebersicht, den Red Button druecken,
|
||||
Grund eintragen, Stick (virtuell) beruehren -- danach muss der Killswitch gesperrt sein, im Register
|
||||
genau ein Ereignis "glattstellen" mit Ausloeser "pascal" stehen und der Zaehler des Authenticators
|
||||
gestiegen sein. Dazu: kein sichtbares "Glattstellen" mehr, kein Wortfeld mit Schluessel, unter
|
||||
127.0.0.1 der Satz "nur unter" ohne POST, und ein Screenshot des Dialogs.
|
||||
|
||||
Was der Lauf nicht misst: eine abgelehnte Signatur (der virtuelle Authenticator signiert immer gueltig),
|
||||
das Bestaetigungswort ohne Schluessel, die Protokollbremse. Das deckt tests/test_dashboard_red_button.py.
|
||||
|
||||
Aufruf: python -X utf8 browser_r.py <port> <ausgabeordner-der-probe> <ordner-fuer-screenshots>
|
||||
"""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
HIER = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HIER))
|
||||
import browser_w # noqa: E402 -- totp, schritt, melde_mit_code_an, ZUGANG, PORT
|
||||
|
||||
PORT = int(sys.argv[1])
|
||||
ORDNER = Path(sys.argv[2])
|
||||
BILDER = Path(sys.argv[3])
|
||||
BILDER.mkdir(parents=True, exist_ok=True)
|
||||
schritt = browser_w.schritt
|
||||
ZUGANG = browser_w.ZUGANG
|
||||
|
||||
|
||||
def ereignisse() -> list[tuple]:
|
||||
db = ORDNER / "registry.db"
|
||||
with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as verbindung:
|
||||
spalten = [z[1] for z in verbindung.execute("PRAGMA table_info(killswitch_ereignisse)")]
|
||||
zeilen = verbindung.execute("SELECT * FROM killswitch_ereignisse ORDER BY rowid").fetchall()
|
||||
return [dict(zip(spalten, z)) for z in zeilen]
|
||||
|
||||
|
||||
def freigeben_falls_gesperrt(seite) -> None:
|
||||
"""Der Red Button ist nur im Zustand aktiv oder angehalten bedienbar; nach einem Notaus erst freigeben."""
|
||||
if "Killswitch gesperrt" in seite.inner_text("body"):
|
||||
seite.get_by_role("button", name="Freigeben").click()
|
||||
# Nach dem Freigeben ist der Zustand aktiv (oder angehalten) -- massgeblich ist, dass der
|
||||
# Red Button wieder bedienbar ist; auf den Text eines bestimmten Zustands zu warten, war falsch.
|
||||
knopf = seite.get_by_role("button", name="Red Button")
|
||||
for _ in range(30):
|
||||
if knopf.is_enabled():
|
||||
break
|
||||
seite.wait_for_timeout(500)
|
||||
else:
|
||||
raise AssertionError("Red Button nach dem Freigeben nicht bedienbar")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
return _lauf()
|
||||
finally:
|
||||
print("\n".join(browser_w.schritte))
|
||||
print(f"\n{len(browser_w.schritte) - len(browser_w.befunde)} von {len(browser_w.schritte)} ok; Befunde: {len(browser_w.befunde)}")
|
||||
print(f"Screenshots unter {BILDER}")
|
||||
|
||||
|
||||
def _lauf() -> int:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
kontext = browser.new_context(viewport={"width": 1280, "height": 900})
|
||||
seite = kontext.new_page()
|
||||
konsole: list[str] = []
|
||||
api: list[str] = []
|
||||
seite.on("console", lambda m: konsole.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None)
|
||||
seite.on("pageerror", lambda e: konsole.append(f"pageerror: {e}"))
|
||||
seite.on("response", lambda r: api.append(f"{r.status} {r.request.method} {r.url.split(str(PORT), 1)[-1]}") if "/api/" in r.url else None)
|
||||
|
||||
cdp = kontext.new_cdp_session(seite)
|
||||
cdp.send("WebAuthn.enable")
|
||||
authenticator = cdp.send("WebAuthn.addVirtualAuthenticator", {"options": {
|
||||
"protocol": "ctap2", "transport": "usb", "hasResidentKey": False,
|
||||
"hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True,
|
||||
}})["authenticatorId"]
|
||||
|
||||
def zugaenge() -> list[dict]:
|
||||
return cdp.send("WebAuthn.getCredentials", {"authenticatorId": authenticator})["credentials"]
|
||||
|
||||
seite.goto(f"http://localhost:{PORT}/")
|
||||
seite.locator("#anmeldung-benutzer").wait_for(timeout=15000)
|
||||
zaehler = browser_w.melde_mit_code_an(seite, -1)
|
||||
schritt("Anmeldung mit Passwort und Code", True)
|
||||
|
||||
# Vor der Registrierung: der Knopf heisst Red Button, "Glattstellen" steht nirgends sichtbar.
|
||||
knopf = seite.get_by_role("button", name="Red Button")
|
||||
schritt("Der Knopf in der Kopfzeile heisst Red Button", knopf.count() == 1)
|
||||
schritt("Kein sichtbares 'Glattstellen' auf der Seite", "Glattstellen" not in seite.inner_text("body"))
|
||||
|
||||
# Schluessel registrieren (wie in browser_w.py).
|
||||
seite.get_by_text("Zugang", exact=True).first.click()
|
||||
seite.locator("#zugang-bezeichnung").wait_for(timeout=10000)
|
||||
seite.fill("#zugang-bezeichnung", "Virtueller Schluessel")
|
||||
seite.fill("#zugang-passwort", ZUGANG["passwort"])
|
||||
seite.get_by_role("button", name="Schlüssel hinzufügen").click()
|
||||
liste = seite.get_by_role("list", name="Sicherheitsschlüssel")
|
||||
liste.wait_for(timeout=15000)
|
||||
schritt("Schluessel registriert", "Virtueller Schluessel" in liste.inner_text())
|
||||
vorher = zugaenge()
|
||||
zaehler_vorher = vorher[0]["signCount"] if vorher else -1
|
||||
|
||||
# Erst die Gegenprobe unter 127.0.0.1, solange der Killswitch noch angehalten (bedienbar) ist:
|
||||
# der Satz "nur unter", kein Wortfeld, kein POST an den Red Button.
|
||||
zweite = kontext.new_page()
|
||||
zwei_api: list[str] = []
|
||||
zweite.on("response", lambda r: zwei_api.append(f"{r.status} {r.request.method} {r.url.split(str(PORT), 1)[-1]}") if "/api/killswitch" in r.url else None)
|
||||
zweite.goto(f"http://127.0.0.1:{PORT}/")
|
||||
zweite.locator("#anmeldung-benutzer").wait_for(timeout=15000)
|
||||
zaehler = browser_w.melde_mit_code_an(zweite, zaehler)
|
||||
zweite.get_by_role("button", name="Red Button").click()
|
||||
zweite.get_by_label("Grund").wait_for(timeout=10000)
|
||||
zweite.wait_for_timeout(1000)
|
||||
text2 = zweite.inner_text("body")
|
||||
schritt("Unter 127.0.0.1: Hinweis 'nur unter' mit der richtigen Adresse", "nur unter" in text2 and f"localhost:{PORT}" in text2)
|
||||
# Der Knopf "Bestaetigen" (aria-label Ausfuehren) bleibt im Dialog stehen, ist aber gesperrt
|
||||
# (bereit ist ohne Weg falsch) -- gemessen am 2026-09-22; ein fehlender Knopf war zu streng verlangt.
|
||||
ausfuehren = zweite.get_by_label("Ausfuehren")
|
||||
schritt("Unter 127.0.0.1: kein Wortfeld, und der Knopf ist gesperrt",
|
||||
zweite.get_by_label("Bestaetigungswort").count() == 0
|
||||
and (ausfuehren.count() == 0 or not ausfuehren.is_enabled()))
|
||||
schritt(f"Unter 127.0.0.1: kein POST an den Red Button ({zwei_api})", not any("POST" in a and "glattstellen" in a for a in zwei_api))
|
||||
zweite.screenshot(path=str(BILDER / "red-button-127.png"))
|
||||
zweite.close()
|
||||
|
||||
seite.get_by_text("Übersicht", exact=True).first.click()
|
||||
seite.get_by_role("button", name="Red Button").wait_for(timeout=10000)
|
||||
vorher_ereignisse = [e for e in ereignisse() if e.get("stufe") == "glattstellen"]
|
||||
api.clear()
|
||||
|
||||
seite.get_by_role("button", name="Red Button").click()
|
||||
seite.get_by_label("Grund").wait_for(timeout=10000)
|
||||
text = seite.inner_text("body")
|
||||
schritt("Der Dialog nennt den Stick und kein Wortfeld", "Stick" in text and seite.get_by_label("Bestaetigungswort").count() == 0)
|
||||
schritt("Der Dialog sagt, was passiert (Paper-Konto)", "Paper-Konto" in text)
|
||||
seite.fill("input[aria-label='Grund']", "Notaus im Browserlauf")
|
||||
seite.screenshot(path=str(BILDER / "red-button-dialog.png"))
|
||||
seite.get_by_label("Ausfuehren").click()
|
||||
|
||||
# Der Dialog schliesst sich nach der Freigabe; der Zustand wird gesperrt.
|
||||
seite.get_by_label("Grund").wait_for(state="hidden", timeout=20000)
|
||||
seite.wait_for_timeout(1500)
|
||||
aufrufe = [a for a in api if "/api/killswitch/glattstellen" in a]
|
||||
schritt(f"Zwei Aufrufe: beginn und Abschluss mit 200 ({aufrufe})",
|
||||
any(a.startswith("200 POST") and a.endswith("/api/killswitch/glattstellen/beginn") for a in aufrufe)
|
||||
and any(a.startswith("200 POST") and a.endswith("/api/killswitch/glattstellen") for a in aufrufe))
|
||||
nachher = zugaenge()
|
||||
zaehler_nachher = nachher[0]["signCount"] if nachher else -1
|
||||
schritt(f"Der Authenticator wurde gefragt (Zaehler {zaehler_vorher} -> {zaehler_nachher})", zaehler_nachher > zaehler_vorher)
|
||||
neu = [e for e in ereignisse() if e.get("stufe") == "glattstellen"]
|
||||
schritt(f"Genau ein neues Registerereignis glattstellen ({len(neu) - len(vorher_ereignisse)})", len(neu) - len(vorher_ereignisse) == 1)
|
||||
if neu:
|
||||
letztes = neu[-1]
|
||||
schritt(f"Ausloeser pascal und der Grund im Register ({letztes.get('ausloeser')!r})",
|
||||
letztes.get("ausloeser") == "pascal" and "Browserlauf" in str(letztes.get("grund")))
|
||||
schritt("Kopfzeile zeigt gesperrt", "gesperrt" in seite.inner_text("body"))
|
||||
seite.screenshot(path=str(BILDER / "red-button-danach.png"))
|
||||
|
||||
# Das Anmeldeprotokoll: keine Zeile mit weg red-button (nichts wurde abgelehnt), das Wort nirgends.
|
||||
protokoll = ORDNER / "anmeldungen.jsonl"
|
||||
if protokoll.exists():
|
||||
zeilen = protokoll.read_text(encoding="utf-8").splitlines()
|
||||
schritt("Keine abgelehnte Freigabe im Anmeldeprotokoll", not any('"red-button"' in z for z in zeilen))
|
||||
|
||||
schritt(f"Keine Konsolenfehler ({konsole[:3]})", not konsole)
|
||||
browser.close()
|
||||
|
||||
return 2 if browser_w.befunde else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -10,6 +10,7 @@ Aufruf (WSL, aus der Kopie): python start_probe_w.py <port> <ausgabeordner>
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,6 +36,13 @@ ordner = Path(sys.argv[2])
|
||||
# Optional: ein Marktkatalog, der nur gelesen wird (fuer die Lastmessung der Kerzenabrufe).
|
||||
katalog = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
ordner.mkdir(parents=True, exist_ok=True)
|
||||
# Seit Paket E2 (2026-09-22) findet die Plattform die Geheimnisdatei auch ueber TRADING_GEHEIMNISSE
|
||||
# bzw. CREDENTIALS_DIRECTORY -- nach dem Umzug steht die erste in jeder Login-Shell. Die Probe nimmt
|
||||
# beide aus ihrer Umgebung, bevor die App gebaut wird: Sonst baute erzeuge_app den echten
|
||||
# Stille-Waechter und Hausmelder, obwohl im Baum keine config/secrets.toml liegt (Befund der
|
||||
# Abschlusspruefung E2).
|
||||
for name in ("TRADING_GEHEIMNISSE", "CREDENTIALS_DIRECTORY"):
|
||||
os.environ.pop(name, None)
|
||||
assert not Path("config/secrets.toml").exists(), "Die Probe startet nur aus einem Baum ohne Geheimnisdatei"
|
||||
|
||||
zugang_datei = ordner / "zugang.toml"
|
||||
|
||||
@@ -14,6 +14,7 @@ damit sich pruefen laesst, dass das Entfernen des einen den anderen nicht trifft
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import UTC
|
||||
from datetime import datetime
|
||||
@@ -40,6 +41,12 @@ ordner = Path(sys.argv[2])
|
||||
# Stille-Waechter und las dafuer config/paper.toml und config/secrets.toml aus dem Arbeitsordner. Eine
|
||||
# Probe-Instanz im echten Projektordner haette nach 180 s auf das Telefon geklingelt. Deshalb nur aus
|
||||
# einer Kopie oder einem Arbeitsbaum ohne Geheimnisdatei.
|
||||
# Seit Paket E2 (2026-09-22) findet die Plattform die Geheimnisdatei auch ueber TRADING_GEHEIMNISSE
|
||||
# bzw. CREDENTIALS_DIRECTORY -- nach dem Umzug steht die erste in jeder Login-Shell, und die Wache
|
||||
# unten liefe ins Leere. Die Probe nimmt beide aus ihrer Umgebung, bevor die App gebaut wird
|
||||
# (Befund der Abschlusspruefung E2).
|
||||
for name in ("TRADING_GEHEIMNISSE", "CREDENTIALS_DIRECTORY"):
|
||||
os.environ.pop(name, None)
|
||||
if Path("config/secrets.toml").exists():
|
||||
sys.exit(
|
||||
"Abbruch: Im Arbeitsordner liegt config/secrets.toml. Die Probe startet nur aus einer Kopie "
|
||||
|
||||
Reference in new issue
Block a user