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:
Pascal MichelandClaude Opus 5 committed 2026-09-23 04:00:01 +02:00
1 parent 593acdd122
commit d7ffc9a5b5
6 files changed
+233 -5

No files matched your search

+5 -1
View File
@@ -21,16 +21,20 @@ Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Ko
5. **Nach jeder Welle:** `welle_vergleich.py --projekt <projekt> --mat <kopie> [--prosa <rel> …] -- <taskdateien der Welle>`. Prüft, ob genau die Zieldateien geändert sind und ob jede byte-gleich mit der Kopie ist. Es zeigt je Datei nur die erste Abweichung — danach `diff` auf die ganze Datei. Eigene Plankorrekturen im Baum meldet es zu Recht als „außerhalb der Zieldateien".
6. **Escape-Folgen setzen:** `plan_escape.py <datei> <zeile> <codepunkt-hex> …` ersetzt echte Zeichen in einer Zeile durch ihre Escape-Folge, ohne dass eine Schreibschicht mitredet.
7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>]`. `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich.
7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test]`. `tests/conftest.py` gilt dabei als Produktivseite und wird zurückgesetzt; nur wenn die neue Fixture eines Tasks dort liegt, lässt `--conftest-als-test` sie stehen (Beleg unten, 2026-09-22). `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich.
## Was belegt ist
Seit 2026-09-18 nimmt `welle_vergleich.py` unter `## Zieldateien` auch Pfade relativ zum Projekt (ohne Laufwerk und ohne Wurzel), weil Pläne für einen Git-Worktree keine absoluten Pfade nennen; ein `..` darin ist ein Befund wie ein fremder absoluter Pfad. Jede Zeile braucht weiter die Klammer hinter dem Pfad (`(neu)`, `(geändert)`, `(gelöscht)`). `plan_materialisiere.py` erkennt eine Zieldatei ohne Schrägstrich nur mit den Endungen py, ts, tsx, toml, md, css und json — für `compose.yaml` in der Projektwurzel schreibt die Überschrift `./compose.yaml`. Der Treiber fährt dazu drei weitere Fälle (relative Grundlinie, relative Abweichung, `..` nach außen), zusammen 17.
Seit 2026-09-22 behandelt `rot_nachweis.py` `tests/conftest.py` als Produktivseite. Vorher zählte alles unter `tests/` als Testdatei — lag die Korrektur eines Tasks selbst in `conftest.py` (Trading Plattform, Task 369: die Ersatzfunktionen der Netzsperre trugen eine andere Vorgabe als die Originale), wendete der Lauf „ohne Umsetzung" sie mit an und meldete `GRUEN pytest: 147 passed` — der eine Zustand, in dem der Nachweis nichts misst, sah aus wie ein sauberer Befund. Gemessen an derselben Basis-Kopie und demselben Task, altes gegen neues Skript: `GRUEN 147 passed` → `ROT 3 failed, 144 passed` mit der gemeinten Fehlerzeile (`assert ('', '') == ('probe-thema', '')`). Die Gegenprobe für den anderen Fall — eine neue Fixture des Tasks liegt in `conftest.py` (Task 368) — mit `--conftest-als-test` `ROT 1 failed, 11 passed` wie vor der Änderung, ohne Schalter `ROT 6 failed, 6 passed` mit `AttributeError: module 'conftest' has no attribute '_gesperrte_pfade'` als erster Fehlerzeile: rot aus dem falschen Grund, aber sichtbar, und das Skript sagt je Task, wie es `conftest.py` behandelt hat. Was es weiter nicht sieht: ob ein Task beides in `conftest.py` legt, Fixture und Korrektur — dann ist keine der beiden Einstellungen richtig, und der Nachweis läuft von Hand.
Seit 2026-09-20 quotet `rot_nachweis.py` den Arbeitsordner für den WSL-Aufruf (`shlex.quote`). Ein Temp-Pfad mit Leerzeichen — der Benutzername steckt im Scratchpad-Pfad — ließ vorher das `cd` scheitern: Alle sieben Tasks eines Plans der Trading Plattform standen als „ROT pytest: keine Summenzeile" da, ohne dass ein einziger Test lief. Nach der Korrektur derselbe Lauf mit echten Summenzeilen (`3 failed, 17 passed`, `1 error`, …). Wer „keine Summenzeile" bei **jedem** Task sieht, hat kein Rot gemessen, sondern ein kaputtes Werkzeug.
`welle_vergleich_treiber.py` fährt 14 Angriffsfälle gegen `welle_vergleich.py` (Grundlinie zuerst; fremde Datei, unveränderte Zieldatei, Abweichung in erster und letzter Zeile und in der letzten Zieldatei, CRLF, Datei fehlt in der Kopie, Task ohne Zieldateien, Kopie fehlt, Löschung nicht ausgeführt, Prosa-Datei unverändert, Zieldatei außerhalb des Projekts, Datei länger als der Plan). `plan_zeichen.py` hat am 2026-09-17 an einem echten Fund rot gezeigt (U+2028 im Plan). `plan_materialisiere.py` reproduziert Task 108 des Teil-3b-Plans aus dem Stand vor den Wellen byte-genau. `kopie_bauen.py` ist am 2026-09-17 an drei kaputten Eingaben rot gesehen: Ziel existiert schon (Exit 2, nichts kopiert), Ausschluss `node_modules` ohne Gegenstück im Wurzelordner (Befund), und ein aus der Kopie entferntes Paket `trading/data` (die Paketprüfung nennt es; nach dem Zurücklegen leer). Einen Treiber dazu gibt es noch nicht — die drei Fälle sind von Hand gefahren.
Offen seit 2026-09-23: `plan_materialisiere.py` stirbt mit `FileNotFoundError`, wenn die Zieldatei eines „Häng diesen Block ans Ende"-Blocks in der Kopie fehlt (Trading Plattform, Task 384 hängte einen Nachtrag an die Planübersicht an; die Kopie war ohne `docs/plans` gebaut). Ein Absturz mitten im Task lässt die davor angewendeten Blöcke halb drin. Bis das ein Befund ist: Kopien für Pläne, die Plandateien selbst anfassen, mit dem Planordner bauen — und eine Kopie nie nach dem Start der Welle ziehen (sie trägt dann den halben Stand des Arbeitsbaums).
## Was die Skripte nicht sehen
Dateien, die `.gitignore` ausblendet; ob die Kopie selbst sauber ist (eine verschmutzte Kopie erzeugt Fehlalarm, kein falsches Grün); **ob die Kopie älter ist als der Plan** — wird eine Taskdatei nach dem Anwenden noch überarbeitet, meldet `welle_vergleich.py` eine Abweichung, die keine ist (2026-09-17, Teil 4c: der Planer lief noch, als die Kopie entstand). Dann entscheidet `plan_bloecke.py nach` gegen den echten Baum, und die betroffenen Dateien der Kopie werden aus `git show HEAD:<pfad>` neu aufgebaut und der Task erneut angewendet; verwechselbare sichtbare Zeichen (kyrillisches „с"). Sie sind ein Fangnetz, kein Nachweis — den liefert der Lauf mit echten Daten.
+27 -4
View File
@@ -12,6 +12,14 @@ Dann laufen die Testdateien des Tasks. Es tut etwas und berichtet; das Urteil fa
Geltungsbereich und Luecken:
- Testdateien sind Zieldateien unter tests/ sowie *.test.ts und *.test.tsx; alles unter tests/fixtures/
gilt als Testseite und bleibt stehen. Alles andere ist produktiv.
- tests/conftest.py gilt als PRODUKTIVSEITE und wird zurueckgesetzt (Projektvorgabe seit 2026-09-22):
Liegt die Korrektur eines Tasks selbst in conftest.py (Trading Plattform, Task 369: die Ersatzfunktionen
der Netzsperre), wendete das Skript sie im Lauf "ohne Umsetzung" mit an und meldete GRUEN -- der eine
Zustand, in dem der Nachweis nichts misst, sah aus wie ein sauberer Befund. Liegt dagegen eine neue
Fixture des Tasks in conftest.py (Task 368), fehlt sie jetzt im Lauf und die neuen Tests enden als
Sammelfehler -- das ist ROT mit "fixture ... not found" in der ersten Fehlerzeile, also sichtbar.
Wer diesen Fall hat, ruft mit --conftest-als-test auf; dann gilt conftest.py wie frueher als Testdatei.
Das Skript sagt je Task, wie es conftest.py behandelt hat.
- Python-Tests laufen in WSL (Debian, ~/venvs/trading), Oberflaechentests unter Windows mit npx vitest;
dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest sie vor dem Loeschen.
- Erzeugte Fixtures (der Plan laesst sie mit altem Code erzeugen) holt --fixturen aus einem Ordner nach,
@@ -20,8 +28,9 @@ Geltungsbereich und Luecken:
- Die Basis ist eine Kopie, die der Aufrufer VOR der ersten Welle gezogen hat; das Skript fasst das
Projekt selbst nie an.
Aufruf: python rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>]
Aufruf: python rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test]
--node-modules nennt das node_modules des Projekts, wenn die Basis-Kopie keines traegt.
--conftest-als-test laesst tests/conftest.py als Testdatei stehen (nur, wenn die neue Fixture des Tasks dort liegt).
spec.json: [{"task": "task-141-....md", "vor": ["task-...md", ...]}, ...]
"""
@@ -37,6 +46,7 @@ from pathlib import Path
HIER = Path(__file__).resolve().parent
ZIEL = re.compile(r"^- `(.+?)`")
PROJEKTNAME = "trading-plattform/"
CONFTEST = "tests/conftest.py"
def zieldateien(taskdatei: Path) -> list[str]:
@@ -59,7 +69,11 @@ def zieldateien(taskdatei: Path) -> list[str]:
return ziele
def ist_test(pfad: str) -> bool:
def ist_test(pfad: str, conftest_als_test: bool = False) -> bool:
if pfad == CONFTEST:
# Vorgabe: produktiv. Sonst wendet der Lauf "ohne Umsetzung" eine Korrektur an, die in
# conftest.py liegt, und meldet gruen (siehe Kopfkommentar).
return conftest_als_test
return pfad.startswith("tests/") or pfad.endswith((".test.ts", ".test.tsx"))
@@ -105,6 +119,10 @@ def main() -> int:
stelle = argumente.index("--node-modules")
node_modules_arg = Path(argumente[stelle + 1])
del argumente[stelle : stelle + 2]
conftest_als_test = False
if "--conftest-als-test" in argumente:
conftest_als_test = True
argumente.remove("--conftest-als-test")
if len(argumente) != 4:
print(__doc__)
return 3
@@ -134,9 +152,14 @@ def main() -> int:
print(f"{task}: ANWENDEN GESCHEITERT: {fehler[-300:]}")
continue
ziele = zieldateien(planordner / task)
tests = [z for z in ziele if ist_test(z) and not z.startswith("tests/fixtures/")]
tests = [z for z in ziele if ist_test(z, conftest_als_test) and not z.startswith("tests/fixtures/")]
if CONFTEST in ziele:
# Sichtbar machen, was mit conftest.py geschah -- der Koordinator liest die erste Fehlerzeile
# anders, wenn eine Fixture fehlt, als wenn eine Korrektur zurueckgesetzt wurde.
wie = "als Testdatei stehen gelassen (--conftest-als-test)" if conftest_als_test else "als Produktivseite auf den Stand vor dem Task zurueckgesetzt"
print(f"{task}: Hinweis: {CONFTEST} {wie}")
for ziel in ziele:
if ist_test(ziel):
if ist_test(ziel, conftest_als_test):
if not (rot / ziel).exists() and fixturen is not None and (fixturen / Path(ziel).name).is_file():
(rot / ziel).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(fixturen / Path(ziel).name, rot / ziel)
@@ -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 "