diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index e797d30..464c80d 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -421,6 +421,81 @@ jobs: retention-days: 7 if-no-files-found: ignore + # ── Stage 3d : M8 Canvas-authored provenance pre-flight ────────── + # M8 (ADR Pulsar-002 + Orion-001) authors a multi-blueprint Canvas+Blue + # scene, pushes it through the REAL Orion compile, drives the active + # scene, mints a viewer show-token, and PROVES the on-air Solar frame is + # that authoring (round-trip active_scene_id + modal-colour). This job + # runs the PROVENANCE PRE-FLIGHT only (--preflight-only via run-m8.ps1): + # it needs a reachable deployed stack (VPS via tunnel, Orion in dual mode + # serving Solar v0.2.0) + a short-TTL operator JWT, so it is GATED on the + # M8 secrets being present and is non-blocking when they are absent + # (the full broadcast leg + the VPS bring-up are a separate, serialised + # run Probe/Keeper drive — ADR §A1.7). Mirrors the M6 job: typed skip + # (exit 3) on a LIGHT build, fail (1) on any assertion, pass (0) only on + # a confirmed provenance pre-flight. The wrapper grep-asserts no + # credential leaks (criterion 7) regardless of probe exit. + m8-provenance: + name: M8 Canvas-authored provenance pre-flight + needs: build + runs-on: windows-2022 + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Download pulsar-rundir + uses: actions/download-artifact@v4 + with: + name: pulsar-rundir + path: . + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install probe deps + shell: bash + run: | + python -m pip install --upgrade pip + pip install websockets + + - name: Run M8 provenance pre-flight (grep-asserted wrapper) + # The wrapper tees stdout, runs the probe in --preflight-only, then + # FAILS the run if any credential leaks to stdout / PNG / VOD — + # redaction is a gate here, not a nicety (Bastion PV-1/CC-1). + # Skip cleanly when the M8 secrets aren't configured on this repo. + env: + M8_OPERATOR_TOKEN: ${{ secrets.M8_OPERATOR_TOKEN }} + M8_GATEWAY_URL: ${{ secrets.M8_GATEWAY_URL }} + shell: pwsh + run: | + if (-not $env:M8_OPERATOR_TOKEN -or -not $env:M8_GATEWAY_URL) { + Write-Host "::notice::M8 secrets (M8_OPERATOR_TOKEN / M8_GATEWAY_URL) not configured — skipping the provenance pre-flight (non-blocking; the real run is the serialised VPS go-live driven by Probe/Keeper)." + exit 0 + } + pwsh scripts/run-m8.ps1 -PreflightOnly -ShowStreamPath stream.lsdp -SolarVersion 0.2.0 -PythonExe python + $rc = $LASTEXITCODE + if ($rc -eq 3) { + Write-Host "::notice::M8 typed skip (exit 3) — LIGHT build without CEF. Needs scripts/build-win.ps1 -Full." + exit 0 + } + exit $rc + + - name: Upload M8 provenance proof PNG + if: always() + uses: actions/upload-artifact@v4 + with: + name: pulsar-m8-provenance-proof + path: | + build/m8-canvas-scene.png + build/m8-probe-stdout.log + retention-days: 30 + if-no-files-found: ignore + # ── Stage 4 : publish proof MP4 to gh-pages ────────────────────── publish-gh-pages: name: publish proof to gh-pages diff --git a/scripts/fixtures/m8-blueprint-score.json b/scripts/fixtures/m8-blueprint-score.json new file mode 100644 index 0000000..8dc4a77 --- /dev/null +++ b/scripts/fixtures/m8-blueprint-score.json @@ -0,0 +1,54 @@ +{ + "_fixture": "M8 score blueprint (Pulsar #45). A pure, bounded compute graph: two core.literal@1 constants (40 + 2) feed a core.math.add@1, whose result is written to the core.output@1 sink leaf `value`. Bound in the scene under blueprint key `score` -> leaf path `score.value` (40 + 2 = 42, the deterministic 'M8 OK 42' marker). Every node is is_pure/is_bounded in Blue's stdlib manifest, so Orion's compiler accepts it by construction (no IMPURE_COMPUTE / COMPILE_FAILED). Field names mirror Blue's graph schema (src/blue/schemas/graph.py) which Orion's FetchBlueprint reads two-call: GET /blueprints/{id} -> current_version, GET /blueprints/{id}/versions/{n} -> graph.{nodes,edges}.", + "blueprint": { + "slug": "pulsar-m8-score", + "name": "Pulsar M8 — score", + "kind": "function", + "tags": ["pulsar-m8", "fixture"], + "interface": { + "inputs": [], + "outputs": [ + { "name": "value", "type": "core.primitive.number" } + ], + "side_effects": [] + } + }, + "graph": { + "nodes": [ + { + "id": "lit.a", + "definition": "core.literal@1", + "config": { "value": 40 }, + "outputs": [{ "id": "lit.a.out", "name": "out", "type": "core.primitive.number" }] + }, + { + "id": "lit.b", + "definition": "core.literal@1", + "config": { "value": 2 }, + "outputs": [{ "id": "lit.b.out", "name": "out", "type": "core.primitive.number" }] + }, + { + "id": "add", + "definition": "core.math.add@1", + "config": {}, + "inputs": [ + { "id": "add.x", "name": "x", "type": "core.primitive.number" }, + { "id": "add.y", "name": "y", "type": "core.primitive.number" } + ], + "outputs": [{ "id": "add.out", "name": "out", "type": "core.primitive.number" }] + }, + { + "id": "out.value", + "definition": "core.output@1", + "config": { "name": "value" }, + "inputs": [{ "id": "out.value.in", "name": "in", "type": "core.primitive.number" }] + } + ], + "edges": [ + { "id": "e.a", "from_node": "lit.a", "from_port": "out", "to_node": "add", "to_port": "x" }, + { "id": "e.b", "from_node": "lit.b", "from_port": "out", "to_node": "add", "to_port": "y" }, + { "id": "e.out", "from_node": "add", "from_port": "out", "to_node": "out.value", "to_port": "in" } + ], + "variables": [] + } +} diff --git a/scripts/fixtures/m8-blueprint-timer.json b/scripts/fixtures/m8-blueprint-timer.json new file mode 100644 index 0000000..22983f8 --- /dev/null +++ b/scripts/fixtures/m8-blueprint-timer.json @@ -0,0 +1,36 @@ +{ + "_fixture": "M8 timer blueprint (Pulsar #45). The SECOND, DISTINCT Blue blueprint that makes the M8 scene a genuine N>=2 multi-blueprint scene (ADR Orion-001 / ADR Pulsar-002 A1.3). A single pure core.literal@1 constant feeds a core.output@1 sink leaf `value`. Bound under blueprint key `timer` -> leaf path `timer.value`. Distinct blueprint id + distinct scene-local key from `score`, both declaring leaf `value`: without the . prefix the two would collide; with it they split into score.value / timer.value (ADR Orion-001 §3.3). Pure/bounded by construction.", + "blueprint": { + "slug": "pulsar-m8-timer", + "name": "Pulsar M8 — timer", + "kind": "function", + "tags": ["pulsar-m8", "fixture"], + "interface": { + "inputs": [], + "outputs": [ + { "name": "value", "type": "core.primitive.string" } + ], + "side_effects": [] + } + }, + "graph": { + "nodes": [ + { + "id": "lit.t", + "definition": "core.literal@1", + "config": { "value": "12:00" }, + "outputs": [{ "id": "lit.t.out", "name": "out", "type": "core.primitive.string" }] + }, + { + "id": "out.value", + "definition": "core.output@1", + "config": { "name": "value" }, + "inputs": [{ "id": "out.value.in", "name": "in", "type": "core.primitive.string" }] + } + ], + "edges": [ + { "id": "e.t", "from_node": "lit.t", "from_port": "out", "to_node": "out.value", "to_port": "in" } + ], + "variables": [] + } +} diff --git a/scripts/fixtures/m8-scene.lsml.json b/scripts/fixtures/m8-scene.lsml.json new file mode 100644 index 0000000..7c1b434 --- /dev/null +++ b/scripts/fixtures/m8-scene.lsml.json @@ -0,0 +1,44 @@ +{ + "lsml": "1.1", + "scene_id": "0c8a9e57-0000-4000-8000-000000000m80", + "scene_version": "sha256:c2e52819d3f465b8228a23f764249b8c86d06f1d5887e68bcaba47bc3dac59c7", + "layout": { + "kind": "frame", + "id": "root", + "size": { "w": 1920, "h": 1080 }, + "background": "#1A9E57", + "children": [ + { + "kind": "stack", + "id": "col", + "direction": "vertical", + "gap": 24, + "align": "center", + "justify": "center", + "children": [ + { + "kind": "text", + "id": "score-label", + "style": { "fontSize": 96, "fontWeight": 800, "color": "#FFFFFF", "textAlign": "center", "fontFamily": "Inter" }, + "bind": { "value": "score.value" } + }, + { + "kind": "text", + "id": "timer-label", + "style": { "fontSize": 48, "fontWeight": 600, "color": "#0A0A0A", "textAlign": "center", "fontFamily": "Inter" }, + "bind": { "value": "timer.value" } + }, + { + "kind": "shape", + "id": "chip", + "geometry": "rect", + "size": { "w": 320, "h": 96 }, + "fill": "#0AF0C8", + "cornerRadius": 16, + "stroke": { "color": "#0A0A0A", "width": 3 } + } + ] + } + ] + } +} diff --git a/scripts/m8_setup.py b/scripts/m8_setup.py new file mode 100644 index 0000000..e7953fa --- /dev/null +++ b/scripts/m8_setup.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""M8 SETUP leg — author a Canvas+Blue scene, push it through Orion, make +it the active show, mint a viewer show-token, and compose the Solar URL. + +This is the *authoring + activation prologue* the M8 probe runs BEFORE the +(reused) M6 pre-flight + broadcast core (ADR Pulsar-002 §3.3/§3.4). The +probe itself is a thin WS-to-Pulsar client; everything in this module is +gateway-first HTTP against the deployed (or local-compose) stack. + +What it does, in order (ADR Pulsar-002 §3.3, §A1): + + S2. Ensure the N (>=2) Blue blueprints exist + are published, idempotent + by slug (POST /blue/api/v1/blueprints, POST .../versions, .../publish). + Each is a pure compute graph (core.literal/core.math.add/core.output) + so Orion's compiler accepts it by construction — no IMPURE_COMPUTE. + S3. PUT the deterministic, checked-in LSML scene bundle into Canvas's A0 + content-addressed store (POST-equivalent PUT /canvas/api/v1/lsml-bundles/{H}). + H is the bundle's own scene_version, computed here the SAME way + lumencast-go/lsml.HashBundle computes it (sorted keys, no whitespace, + no HTML escaping, scene_version zeroed) so the store address-check + passes and /layouts/{H} serves the layout to Orion's compiler. + S1. Ensure the test scene row exists in Canvas (status=ready), idempotent + by name (POST /canvas/api/v1/scenes), capture scene_id. + S4. Save a definition revision carrying canvas_version=H + blueprints[] + ({key,id} for each blueprint) (POST /canvas/.../scenes/{id}/save). + S5. Push that revision through Orion (POST /canvas/.../scenes/{id}/push, + definition_id + lsml_bundle_hash=H). Assert 200, capture scene_version. + S6. Drive the active-scene (POST /orion/api/v1/show/active-scene {scene_id}). + M8 is the FIRST real driver of this endpoint (ADR §1.1 active-scene gap). + S7. Round-trip GET /orion/api/v1/show — assert active_scene_id == scene_id + (provenance marker 3, server-side, deterministic). + S8. Mint a viewer show-token (POST /auth/api/v1/show-tokens, operator-only). + S9. Compose the Solar v0.2.0 LSDP URL (getSolarSceneUrl parity). + +SECRET HYGIENE (Bastion PV-1 / CC-1, ADR §A1.5): + - The SETUP operator credential is read from the environment ONLY + (M8_OPERATOR_TOKEN, sourced from the étage-1 secret file). It is a + short-TTL admin token and is NEVER `ORION_OPERATOR_TOKEN` (the + long-lived exp-2027 service token). It is never logged. + - The minted viewer show-token is redacted by `redact_solar_url` + (a Python port of Prism's broadcast-url.ts::redactSolarUrl) in every + line that could carry it, and by `redact_token` everywhere else. + - NO token is committed anywhere in this module or the fixtures. +""" +from __future__ import annotations + +import hashlib +import json +import os +import pathlib +import re +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from typing import Any, Optional + +FIXTURES_DIR = pathlib.Path(__file__).resolve().parent / "fixtures" +SCENE_BUNDLE_FIXTURE = FIXTURES_DIR / "m8-scene.lsml.json" +BLUEPRINT_FIXTURES = [ + ("score", FIXTURES_DIR / "m8-blueprint-score.json"), + ("timer", FIXTURES_DIR / "m8-blueprint-timer.json"), +] + +# The Canvas scene name M8 authors/reuses idempotently. A fixed name keeps +# the SETUP idempotent across runs (find-or-create by name). +SCENE_NAME = "Pulsar M8 — Canvas-authored live test" + +# Solar bundle version the M8 probe composes its URL against (ADR §A1.2: +# the LSDP wire ships as @zablab/solar@0.2.0, served at /static/solar/v0.2.0/). +DEFAULT_SOLAR_VERSION = "0.2.0" +# Default wire path: the LSDP wire (ADR §A1.1 maximal path). The bespoke +# `stream` value stays a supported fallback (§6.6). +DEFAULT_SHOW_STREAM_PATH = "stream.lsdp" + + +# -------------------------------------------------------------------------- +# Secret redaction — PORT of Prism/src/main/broadcast-url.ts::redactSolarUrl. +# The show-token lives url-encoded inside the ?orion= param (token%3D<...>) +# and/or as a plain ?token=<...>; strip both so a log line can show the +# page + version without leaking the credential. +# -------------------------------------------------------------------------- +_TOKEN_ENC_RE = re.compile(r"token%3D[^%&]+", re.IGNORECASE) +_TOKEN_PLAIN_RE = re.compile(r"([?&]token=)[^&]+", re.IGNORECASE) + + +def redact_solar_url(url: str) -> str: + """Redact the show-token out of a Solar URL for safe logging. + + Mirrors broadcast-url.ts::redactSolarUrl byte-for-byte: the nested + url-encoded ``token%3D<...>`` (up to the next ``%26``/``&`` or EOS) and + the plain ``?token=<...>`` form are both replaced with ````. + """ + out = _TOKEN_ENC_RE.sub("token%3D", url) + out = _TOKEN_PLAIN_RE.sub(r"\1", out) + return out + + +def redact_token(text: str, *secrets: str) -> str: + """Replace any non-empty secret substring with ````. + + Belt-and-braces alongside ``redact_solar_url``: the raw show-token, the + operator JWT, and the Twitch key are scrubbed from any free-form line + (exception messages, response bodies) where they could otherwise leak. + """ + out = text + for sec in secrets: + if sec and sec in out: + out = out.replace(sec, "") + return out + + +# -------------------------------------------------------------------------- +# Canonical LSML hash — PORT of lumencast-go/lsml/hash.go::HashBundle. +# We never let the probe INVENT a hash: it computes the bundle's own +# scene_version exactly as Go (and @lumencast/compiler) do, so the Canvas +# A0 store's address-check (bundle.scene_version hex == {hash} path) passes +# and /layouts/{H} resolves. The same discipline that keeps Go and TS in +# byte-agreement (sorted keys, no whitespace, no HTML escaping, scene_version +# zeroed) is reproduced here. +# -------------------------------------------------------------------------- +_ZERO_HASH = "sha256:" + "0" * 64 + + +def _canonical(value: Any) -> str: + """Emit canonical JSON for ``value`` — sorted keys at every level, no + insignificant whitespace, and NO HTML escaping of ``&<>`` (hash.go + marshalString disables Go's SetEscapeHTML; Python's json never escapes + those, so the default already matches). Numbers use the shortest form + json.dumps already produces for ints/floats. + """ + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + + +def hash_bundle(bundle: dict[str, Any]) -> str: + """Return the bare lowercase-hex sha256 of the canonicalised bundle, + with ``scene_version`` replaced by the zero placeholder (hash.go rules). + This is the store address {H} and the bundle's own scene_version hex. + """ + work = dict(bundle) + if "scene_version" in work: + work["scene_version"] = _ZERO_HASH + canon = _canonical(work).encode("utf-8") + return hashlib.sha256(canon).hexdigest() + + +def load_scene_bundle() -> tuple[dict[str, Any], str]: + """Load the checked-in scene bundle, stamp its real scene_version, and + return ``(bundle, H)``. The fixture ships with a zeroed scene_version + placeholder; we compute H and seal it so the stored bundle + self-certifies its identity (lsml_bundle_service.bundle_hash). + """ + bundle = json.loads(SCENE_BUNDLE_FIXTURE.read_text(encoding="utf-8")) + h = hash_bundle(bundle) + bundle["scene_version"] = "sha256:" + h + return bundle, h + + +# -------------------------------------------------------------------------- +# Minimal gateway-first HTTP client (stdlib only — no extra probe deps). +# All calls go through ZabGate; the operator JWT rides as a Bearer header +# (the gateway validates it + injects X-Authenticated-Role for Orion's +# requireOperator / ZabAuth require_operator). +# -------------------------------------------------------------------------- +class SetupError(RuntimeError): + """A SETUP-leg failure with a redacted, self-explanatory message.""" + + +@dataclass +class GatewayClient: + base_url: str # e.g. http://127.0.0.1:8099 (tunnel'd gateway) + operator_token: str # admin/operator JWT (étage-1, never logged) + secrets: list[str] = field(default_factory=list) + timeout: float = 30.0 + + def _request( + self, method: str, path: str, *, body: Optional[dict] = None, + auth: bool = True, expect: tuple[int, ...] = (200, 201), + ) -> tuple[int, dict]: + url = self.base_url.rstrip("/") + path + data = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json") + if auth and self.operator_token: + req.add_header("Authorization", f"Bearer {self.operator_token}") + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + status = resp.status + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + status = exc.code + raw = exc.read().decode("utf-8", "replace") + except urllib.error.URLError as exc: + raise SetupError( + redact_token(f"{method} {path} unreachable: {exc}", *self.secrets) + ) from None + try: + payload = json.loads(raw) if raw else {} + except ValueError: + payload = {"_raw": raw} + if status not in expect: + safe = redact_token(raw[:400], *self.secrets) + raise SetupError( + f"{method} {path} -> {status} (want {expect}): {safe}" + ) + return status, payload if isinstance(payload, dict) else {"_list": payload} + + # -- Blue ------------------------------------------------------------ + def find_blueprint_by_slug(self, slug: str) -> Optional[dict]: + _, payload = self._request("GET", "/blue/api/v1/blueprints", expect=(200,)) + items = payload.get("_list", payload) + if isinstance(items, list): + for bp in items: + if isinstance(bp, dict) and bp.get("slug") == slug: + return bp + return None + + def ensure_blueprint(self, fixture: dict) -> str: + """Idempotently create + publish one Blue blueprint from a fixture; + return its id. Re-runs are no-ops (find-by-slug short-circuits).""" + spec = fixture["blueprint"] + slug = spec["slug"] + existing = self.find_blueprint_by_slug(slug) + if existing is not None and int(existing.get("current_version", 0)) >= 1: + return str(existing["id"]) + + if existing is None: + _, bp = self._request("POST", "/blue/api/v1/blueprints", body={ + "slug": slug, + "name": spec["name"], + "kind": spec["kind"], + "tags": spec.get("tags", []), + "interface": spec.get("interface", {}), + }, expect=(201,)) + else: + bp = existing + bp_id = str(bp["id"]) + + # Create a draft version carrying the compute graph, then publish so + # current_version advances to 1 (Orion's FetchBlueprint reads + # GET /versions/{current_version}). + _, ver = self._request( + "POST", f"/blue/api/v1/blueprints/{bp_id}/versions", + body={"graph": fixture["graph"], "interface": spec.get("interface", {})}, + expect=(201,), + ) + version_n = int(ver["version"]) + self._request( + "POST", f"/blue/api/v1/blueprints/{bp_id}/versions/{version_n}/publish", + expect=(200,), + ) + return bp_id + + # -- Canvas ---------------------------------------------------------- + def find_scene_by_name(self, name: str) -> Optional[dict]: + _, payload = self._request( + "GET", + "/canvas/api/v1/scenes?q=" + urllib.parse.quote(name), + expect=(200,), + ) + items = payload.get("_list", payload) + if isinstance(items, list): + for sc in items: + if isinstance(sc, dict) and sc.get("name") == name: + return sc + return None + + def ensure_scene(self, name: str) -> str: + existing = self.find_scene_by_name(name) + if existing is not None: + return str(existing["id"]) + _, sc = self._request("POST", "/canvas/api/v1/scenes", body={ + "name": name, + "status": "ready", + }, expect=(201,)) + return str(sc["id"]) + + def put_lsml_bundle(self, bundle: dict, h: str) -> None: + """Store the LSML bundle under its content address {H} (idempotent; + 200 on re-PUT, 201 first store). archive is the bundle JSON bytes — + the store keeps it byte-faithfully but addresses by scene_version, + not by sha256(archive).""" + import base64 + archive_b64 = base64.b64encode( + json.dumps(bundle).encode("utf-8") + ).decode("ascii") + self._request( + "PUT", f"/canvas/api/v1/lsml-bundles/{h}", + body={"bundle": bundle, "archive": archive_b64}, + expect=(200, 201), + ) + + def save_definition( + self, scene_id: str, canvas_version: str, blueprints: list[dict], + ) -> str: + """Append a definition revision binding the N blueprints (blueprints[] + = [{key,id}, ...]) at canvas_version=H. Returns the definition id.""" + _, rev = self._request( + "POST", f"/canvas/api/v1/scenes/{scene_id}/save", + body={ + "canvas_version": canvas_version, + "blueprints": blueprints, + "components": [], + }, + expect=(200, 201), + ) + return str(rev["id"]) + + def push_definition( + self, scene_id: str, definition_id: str, lsml_bundle_hash: str, + ) -> dict: + """Push the revision through Orion. Returns the push response + {scene_version, diagnostics{errors,warnings}}. Raises on + non-200 or non-empty diagnostics.errors.""" + _, resp = self._request( + "POST", f"/canvas/api/v1/scenes/{scene_id}/push", + body={"definition_id": definition_id, "lsml_bundle_hash": lsml_bundle_hash}, + expect=(200,), + ) + diags = resp.get("diagnostics") or {} + errors = diags.get("errors") or [] + if errors: + raise SetupError( + f"push diagnostics.errors non-empty: {json.dumps(errors)[:400]}" + ) + return resp + + # -- Orion show ------------------------------------------------------ + def set_active_scene(self, scene_id: str) -> None: + self._request( + "POST", "/orion/api/v1/show/active-scene", + body={"scene_id": scene_id}, expect=(200,), + ) + + def get_show(self) -> dict: + # Operator-gated on the public gateway: GET /orion/api/v1/show is 401 + # without a Bearer, 200 with the operator Bearer. Ride the operator + # JWT (auth=True) like every other SETUP leg. + _, show = self._request("GET", "/orion/api/v1/show", auth=True, expect=(200,)) + return show + + # -- ZabAuth --------------------------------------------------------- + def mint_show_token(self, ttl_s: int = 14400) -> str: + _, tok = self._request( + "POST", "/auth/api/v1/show-tokens", + body={"ttl_s": ttl_s}, expect=(201,), + ) + access = tok.get("access_token") + if not isinstance(access, str) or not access: + raise SetupError("show-token mint returned no access_token") + return access + + +# -------------------------------------------------------------------------- +# Solar URL composition — PORT of broadcast-url.ts::getSolarSceneUrl, with +# the wire path parameterised (stream.lsdp default, stream bespoke fallback). +# -------------------------------------------------------------------------- +def gateway_to_ws_origin(gateway_url: str) -> str: + u = urllib.parse.urlparse(gateway_url) + scheme = "wss" if u.scheme == "https" else "ws" + return f"{scheme}://{u.netloc}" + + +def compose_solar_url( + *, gateway_url: str, show_token: str, solar_version: str, show_stream_path: str, +) -> str: + """Compose the Solar live-page URL Pulsar's CEF browser_source loads. + + Shape (broadcast-url.ts::getSolarSceneUrl, wire-path parameterised): + /orion/static/solar/v{N}/index.html + ?orion=/orion/api/v1/show/{path}?token=> + &mode=broadcast + + The inner ``?token=`` is url-encoded as the value of the outer + ``?orion=`` param so the nested query survives verbatim. The show is + selected by Orion's active-scene state (driven in S6) — Solar is told + WHICH server to talk to, the server decides the scene. + """ + ws_origin = gateway_to_ws_origin(gateway_url) + lsdp_url = ( + f"{ws_origin}/orion/api/v1/show/{show_stream_path}" + f"?token={urllib.parse.quote(show_token, safe='')}" + ) + http = urllib.parse.urlparse(gateway_url) + page_origin = f"{http.scheme}://{http.netloc}" + solar_page = f"{page_origin}/orion/static/solar/v{solar_version}/index.html" + return ( + f"{solar_page}?orion={urllib.parse.quote(lsdp_url, safe='')}" + f"&mode=broadcast" + ) + + +@dataclass +class SetupResult: + """Everything the probe needs after SETUP, for pre-flight + provenance.""" + + scene_id: str + bundle_hash: str # H — the LSML content address we stored + pushed_scene_version: str # authoritative scene_version from the push + blueprint_ids: dict[str, str] + solar_url: str # show-token EMBEDDED — log only via redact_solar_url + show_token: str # raw — never log; redaction secret source + test_background: str # the known unusual bg colour (provenance marker 1) + + +def background_rgb(bundle: dict[str, Any]) -> tuple[int, int, int]: + """Extract the frame background colour from the scene bundle as an RGB + triple — the modal-colour the pre-flight ties the on-air pixels to.""" + bg = bundle.get("layout", {}).get("background", "") + return hex_to_rgb(bg) + + +def hex_to_rgb(value: str) -> tuple[int, int, int]: + s = value.lstrip("#") + if len(s) == 3: + s = "".join(c * 2 for c in s) + if len(s) != 6: + raise SetupError(f"background colour {value!r} is not a #RRGGBB hex") + return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) + + +def run_setup( + *, gateway_url: str, operator_token: str, twitch_key: str, + solar_version: str, show_stream_path: str, log, +) -> SetupResult: + """Execute the full SETUP leg and return a SetupResult. ``log`` is a + callable taking a single already-redacted string (the probe's print).""" + bundle, h = load_scene_bundle() + bg_rgb = background_rgb(bundle) + client = GatewayClient( + base_url=gateway_url, + operator_token=operator_token, + # Redaction secret set: operator JWT + Twitch key (show-token added + # after mint). Any of these in a response body is scrubbed. + secrets=[s for s in (operator_token, twitch_key) if s], + ) + + log("[S2] ensuring Blue blueprints (idempotent by slug) ...") + blueprint_ids: dict[str, str] = {} + blueprints_wire: list[dict] = [] + for key, fixture_path in BLUEPRINT_FIXTURES: + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + bp_id = client.ensure_blueprint(fixture) + blueprint_ids[key] = bp_id + blueprints_wire.append({"key": key, "id": bp_id}) + log(f" blueprint key={key!r} id={bp_id} ({fixture_path.name})") + if len({b["id"] for b in blueprints_wire}) < 2: + raise SetupError("fixtures did not yield >=2 DISTINCT blueprint ids") + if len({b["key"] for b in blueprints_wire}) != len(blueprints_wire): + raise SetupError("blueprint keys are not unique") + + log(f"[S3] storing LSML bundle in Canvas A0 (H={h}) ...") + client.put_lsml_bundle(bundle, h) + + log("[S1] ensuring Canvas scene row (status=ready, idempotent by name) ...") + scene_id = client.ensure_scene(SCENE_NAME) + log(f" scene_id={scene_id}") + + log("[S4] saving definition revision (canvas_version=H, blueprints[]) ...") + definition_id = client.save_definition(scene_id, h, blueprints_wire) + log(f" definition_id={definition_id}") + + log("[S5] pushing revision through Orion (lsml_bundle_hash=H) ...") + push = client.push_definition(scene_id, definition_id, h) + pushed_version = str(push["scene_version"]) + log(f" push OK scene_version={pushed_version} diagnostics.errors=[]") + if pushed_version == "sha256:" + h: + log(" note: Orion adopted the stored LSML hash (byte-match adopt-on-verify)") + else: + log(" note: Orion minted a legacy scene_version (LSML_HASH_MISMATCH, " + "non-fatal); provenance uses active_scene_id + push scene_version") + + log("[S6] driving active-scene (M8 is the first real driver) ...") + client.set_active_scene(scene_id) + + log("[S7] round-trip GET /orion/show (provenance marker 3) ...") + show = client.get_show() + active = show.get("active_scene_id") + if active != scene_id: + raise SetupError( + f"provenance FAIL: active_scene_id={active!r} != scene_id={scene_id!r}" + ) + log(f" active_scene_id == scene_id ({scene_id}) — server-side provenance OK") + + log("[S8] minting viewer show-token (operator-only) ...") + show_token = client.mint_show_token() + log(" show-token minted ()") + + log("[S9] composing Solar v%s LSDP URL ..." % solar_version) + solar_url = compose_solar_url( + gateway_url=gateway_url, show_token=show_token, + solar_version=solar_version, show_stream_path=show_stream_path, + ) + log(f" solar_url={redact_solar_url(solar_url)}") + + return SetupResult( + scene_id=scene_id, + bundle_hash=h, + pushed_scene_version=pushed_version, + blueprint_ids=blueprint_ids, + solar_url=solar_url, + show_token=show_token, + test_background="#%02X%02X%02X" % bg_rgb, + ) diff --git a/scripts/probe-m6-live.py b/scripts/probe-m6-live.py index 5a3e115..80d36d5 100644 --- a/scripts/probe-m6-live.py +++ b/scripts/probe-m6-live.py @@ -166,6 +166,16 @@ POLL_INTERVAL_SEC = 5.0 DESTINATION_NAME = "pulsar-m6-live" +# StartDestination boot-race retry (ported verbatim from +# probe-twitch-live.py's START_DEST_* pattern, validated by Vigil on the +# scene-switch). The frontend streaming output can briefly be unavailable +# right after a scene-switch/boot; that ONE exact transient error is polled +# out within a bounded budget. Any OTHER error is a hard failure on the first +# attempt, and an exhausted budget is a hard failure too -- zero masking. +START_DEST_BOOT_ERROR = "frontend streaming output unavailable" +START_DEST_RETRY_BUDGET = 20.0 # seconds to wait out the boot race +START_DEST_RETRY_DELAY = 1.0 # poll cadence between attempts + LIVE_VOD_DIR = (BUILD_DIR / "m6-live-vod") BENIGN_LOG_SUBSTRINGS = [ @@ -649,6 +659,48 @@ def _scan_rtmp_diagnostic(lines: list[str]) -> list[str]: return hits +async def start_destination_with_retry(inbox: Inbox, ws, dest_id: str, + stream_key: str) -> bool: + """StartDestination, polling out the post-boot/scene-switch race. + + Ported from probe-twitch-live.py (START_DEST_* pattern). Returns True once + the destination reports started=true. Returns False on a HARD failure: + either a non-transient StartDestination error (failed on the first + attempt) OR the boot race never cleared within START_DEST_RETRY_BUDGET. + + Strict semantics (validated by Vigil on the scene-switch): the retry fires + ONLY when the error is exactly START_DEST_BOOT_ERROR. Any other error ends + the loop immediately. No error string is ever masked or swallowed. + """ + deadline = time.time() + START_DEST_RETRY_BUDGET + attempt = 0 + while True: + attempt += 1 + r = await vendor_call(inbox, ws, f"start-dest-{attempt}", "pulsar", + "StartDestination", {"id": dest_id}) + sd = vendor_response_data(r) + if sd.get("started"): + print(f"-> StartDestination started=true -- LIVE on Twitch " + f"(attempt #{attempt})") + return True + err = str(sd.get("error", "")) + transient = (err == START_DEST_BOOT_ERROR) + if transient and time.time() < deadline: + print(f" start-dest attempt #{attempt}: streaming output not " + f"ready yet ('{err}'), retrying in {START_DEST_RETRY_DELAY}s") + await asyncio.sleep(START_DEST_RETRY_DELAY) + continue + # Either a non-transient error, or the boot race never cleared within + # budget -- both are hard failures. + status = vendor_request_status(r) + reason = ("boot race unresolved after " + f"{START_DEST_RETRY_BUDGET}s ({attempt} attempts)" + if transient else "not started") + print(f"FAIL: StartDestination {reason}; " + f"status={redact(json.dumps(status), stream_key)}") + return False + + async def broadcast(inbox: Inbox, ws, stream_key: str, duration_sec: int, pulsar: "PulsarProcess") -> int: # 1. CreateDestination(twitch). The key is passed opaquely; it never @@ -669,18 +721,14 @@ async def broadcast(inbox: Inbox, ws, stream_key: str, duration_sec: int, return 1 print(f"-> CreateDestination(twitch) id={dest_id}") - # 2. StartDestination -> live. - r = await vendor_call(inbox, ws, "start-dest", "pulsar", - "StartDestination", {"id": dest_id}) - sd = vendor_response_data(r) - if not sd.get("started"): - status = vendor_request_status(r) - print(f"FAIL: StartDestination not started; " - f"status={redact(json.dumps(status), stream_key)}") + # 2. StartDestination -> live, with the bounded anti-boot-race retry + # (the frontend streaming output can be briefly unavailable right after + # a boot/scene-switch -- see start_destination_with_retry). A hard + # failure rolls the destination back, exactly as the single-shot did. + if not await start_destination_with_retry(inbox, ws, dest_id, stream_key): await vendor_call(inbox, ws, "rm-dest", "pulsar", "RemoveDestination", {"id": dest_id}) return 1 - print("-> StartDestination started=true -- LIVE on Twitch") # 2b. StartRecord -- local MP4 as an offline broadcast proof. recording = False diff --git a/scripts/probe-m8-canvas-live.py b/scripts/probe-m8-canvas-live.py new file mode 100644 index 0000000..701bdac --- /dev/null +++ b/scripts/probe-m8-canvas-live.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Pulsar M8 live probe — a Canvas+Blue scene THIS test authored, on air to +Twitch, over the LSDP wire, with provenance (ADR Pulsar-002, ADR Orion-001). + +M8 is M6 (the real Solar page in Pulsar's CEF → Twitch, with a non-blank +pre-flight) but the on-air scene is **authored by M8 itself**: a multi- +blueprint (N>=2) rich Canvas layout + Blue logic, pushed through the real +Orion compile, made the active show, and *proven* to be the scene on screen +— not whatever was ambient behind a tunnel, not a fallback. + +Two things make M8 strictly stronger than M6: + + 1. AUTHORING. The SETUP leg (scripts/m8_setup.py) creates the N Blue + blueprints, stores the deterministic checked-in LSML scene, pushes it, + drives the active-scene (M8 is the FIRST real driver of + POST /orion/api/v1/show/active-scene), and mints a FRESH viewer + show-token (no baked, expiring token in source — Bastion PV-1). + + 2. PROVENANCE. The on-air pixels are tied to the server state two ways: + (3) ROUND-TRIP: GET /orion/api/v1/show -> active_scene_id == scene_id + (deterministic, server-side; done in SETUP S7), and the push + response's scene_version is captured + reported. + (1) MODAL-COLOUR: the captured CEF frame's modal colour ≈ the scene's + known unusual background (#1A9E57) within tolerance — this binds + the SERVER state to the PIXELS. A blank / wrong-colour / fallback + frame fails it => NO GO (no broadcast). + +Everything below the SETUP/provenance is the proven M6 broadcast core, +reused verbatim by importing probe-m6-live.py: CEF spawn/reap, pure-stdlib +PNG decode + analyse_frame, the RTMP metrics loop, secret redaction. The +bounded anti-boot-race StartDestination retry lives in that shared core +(probe-m6-live.py's start_destination_with_retry, ported from +probe-twitch-live.py's START_DEST_* pattern): m6.broadcast goes live through +it, so the M8 broadcast path retries the exact transient +'frontend streaming output unavailable' error within a bounded budget and +hard-fails on anything else. + +SECRET HYGIENE (Bastion PV-1 / CC-1, ADR §A1.5 — load-bearing): + - NO token committed anywhere (no DEFAULT_SOLAR_URL with a baked JWT, the + M6 trap — see probe-m6-live.py:125). + - The Twitch key, the operator JWT, and the minted show-token all come + from the étage-1 environment. + - The operator credential is M8_OPERATOR_TOKEN (admin, short-TTL) — NOT + ORION_OPERATOR_TOKEN (the long-lived service token), CC-1. + - Every line that emits solar_url is passed through redact_solar_url + (the redactSolarUrl port); the Twitch key + JWT + show-token are + scrubbed everywhere else; and a grep-assert in the run wrapper + (run-m8.ps1 / the CI job) fails the run if any credential leaks to + stdout / the proof PNG / the VOD. + +Wire = LSDP by default (--show-stream-path stream.lsdp), Solar v0.2.0. + +Usage (from the repo root, against the built -Full rundir): + pip install websockets + export M8_OPERATOR_TOKEN=... # étage-1 admin JWT, short-TTL, NEVER committed + export TWITCH_STREAM_KEY=... # étage-1, NEVER committed (broadcast leg only) + export M8_GATEWAY_URL=http://127.0.0.1:8099 # tunnel'd gateway base + python scripts/probe-m8-canvas-live.py --preflight-only # author+push+prove, no go-live + python scripts/probe-m8-canvas-live.py # + broadcast + +Exit codes (mirror M6): + 0 pass (provenance pre-flight confirmed; if not --preflight-only, live ok) + 1 fail (setup / provenance / broadcast assertion failed — NO blank go-live) + 2 config error (no operator token, no exe, no key for broadcast, bad args) + 3 typed skip (browser_source not registered — LIGHT build, needs -Full) +""" +from __future__ import annotations + +import argparse +import asyncio +import importlib.util +import os +import pathlib +import secrets +import socket +import sys +import time +from typing import Optional + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + +SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent +REPO_ROOT = SCRIPTS_DIR.parent + +# Ensure the sibling m8_setup module is importable regardless of the CWD the +# probe is launched from (the run wrapper / CI invoke it from the repo root). +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +# Import the M6 broadcast core. probe-m6-live.py has a hyphen in its name +# (not a valid module identifier), so load it by path — we reuse its CEF +# spawn/reap, PNG decode, analyse_frame, broadcast loop and redaction +# WHOLESALE rather than fork 900 lines (ADR §3.5: "M8 imports the broadcast +# core; the non-blank predicate is EXTENDED with the modal-colour assertion"). +_m6_path = SCRIPTS_DIR / "probe-m6-live.py" +_spec = importlib.util.spec_from_file_location("probe_m6_live", _m6_path) +assert _spec is not None and _spec.loader is not None +m6 = importlib.util.module_from_spec(_spec) +sys.modules["probe_m6_live"] = m6 +_spec.loader.exec_module(m6) + +import m8_setup # noqa: E402 (after sys.path is the scripts dir by __file__) + +try: + import websockets # noqa: F401 (used inside m6.run via the shared import) +except ImportError: + print("error: pip install websockets (pure WS client — no native deps)") + sys.exit(2) + + +BUILD_DIR = REPO_ROOT / "build" +PROOF_PNG = BUILD_DIR / "m8-canvas-scene.png" +LIVE_VOD_DIR = BUILD_DIR / "m8-canvas-vod" + +# Modal-colour provenance tolerance (Manhattan distance in RGB). The CEF +# render + PNG re-encode shift colours slightly (anti-aliasing at edges, +# sub-pixel blending), but the dominant background is a large flat field — +# its modal colour stays within a tight band of the authored hex. A +# fallback / blank / different scene lands far outside. +MODAL_COLOUR_TOL = 24 + + +def _provenance_modal_ok(modal: Optional[tuple[int, int, int]], + target: tuple[int, int, int]) -> tuple[bool, int]: + """Return (ok, manhattan_distance) for the modal-colour provenance check.""" + if modal is None: + return False, 1 << 30 + dist = abs(modal[0] - target[0]) + abs(modal[1] - target[1]) + abs(modal[2] - target[2]) + return dist <= MODAL_COLOUR_TOL, dist + + +async def preflight_provenance(inbox, ws, solar_url: str, + target_rgb: tuple[int, int, int], + show_token: str) -> int: + """M6 non-blank pre-flight EXTENDED with the modal-colour provenance + assertion (marker 1). Returns 0 only when the captured frame is both + non-blank AND its modal colour ≈ the test background. Saves the proof + PNG to build/m8-canvas-scene.png. A blank or wrong-colour frame => 1 + (NO GO), with a typed diagnosis. Reuses m6.preflight_non_blank's render + + poll machinery by pointing its PROOF_PNG at the M8 path first. + """ + # Point M6's module-level proof path + capture dirs at the M8 artefacts + # so the reused core writes where the M8 wrapper/CI expect them. + m6.PROOF_PNG = PROOF_PNG + m6.BUILD_DIR = BUILD_DIR + + rc, metrics = await m6.preflight_non_blank(inbox, ws, solar_url) + if rc != 0: + # m6 already printed the non-blank diagnosis + saved the last frame. + print("[M8] pre-flight FAILED at the non-blank stage — NOT going live. " + "Diagnose: LSDP WS to the gateway failed (token/.lsdp gate?), " + "Solar v0.2.0 bundle 404 at /static/solar/v0.2.0/, or the active " + "scene never streamed. See the m6 diagnosis above + the saved PNG.") + return 1 + + # Non-blank confirmed; now the load-bearing provenance: modal ≈ target. + modal = metrics.get("modal") + ok, dist = _provenance_modal_ok(modal, target_rgb) + print(f"[M8] provenance modal-colour check: captured modal={modal} " + f"target={target_rgb} manhattan={dist} tol={MODAL_COLOUR_TOL}") + if not ok: + print("[M8] provenance FAILED — the frame is non-blank but its modal " + "colour does NOT match the scene background this test authored " + f"(#{target_rgb[0]:02X}{target_rgb[1]:02X}{target_rgb[2]:02X}). " + "This is the fallback / wrong-scene trap: Pulsar rendered SOME " + "Solar scene, but not the one M8 pushed + activated. NOT going " + "live. Inspect the proof PNG: " + str(PROOF_PNG)) + return 1 + + print("[M8] PROVENANCE PROVEN — non-blank AND modal colour matches the " + "authored background; the on-air pixels are M8's pushed scene. " + f"Proof PNG: {PROOF_PNG}") + return 0 + + +async def run(ws_url: str, password: str, setup: "m8_setup.SetupResult", + stream_key: str, duration_sec: int, preflight_only: bool, + pulsar) -> int: + import json + print(f"connecting: {ws_url}") + async with websockets.connect( + ws_url, subprotocols=["obswebsocket.json"], max_size=2**24, + ping_interval=None, close_timeout=15, open_timeout=10, + ) as ws: + hello = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + if hello.get("op") != 0: + print(f"error: expected Hello (op=0), got {hello}") + return 1 + identify_d: dict = { + "rpcVersion": hello["d"]["rpcVersion"], + "eventSubscriptions": m6.EVENT_SUBSCRIPTION_ALL, + } + if "authentication" in hello["d"]: + a = hello["d"]["authentication"] + identify_d["authentication"] = m6.compute_auth( + password, a["salt"], a["challenge"]) + await ws.send(json.dumps({"op": 1, "d": identify_d})) + ident = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + if ident.get("op") != 2: + print(f"error: identify failed: {ident}") + return 1 + print("identified (v5 auth OK)") + + inbox = m6.Inbox() + + resp = await m6.request(inbox, ws, "GetInputKindList", "kinds", {}) + kinds = set(resp["responseData"]["inputKinds"]) + if "browser_source" not in kinds: + print("SKIP: browser_source NOT registered — LIGHT build (no CEF). " + "M8 needs a full build (scripts/build-win.ps1 -Full). " + "Typed skip, NOT a pass.") + return 3 + print(f"browser_source registered ({len(kinds)} input kinds total)") + + target_rgb = m8_setup.hex_to_rgb(setup.test_background) + rc = await preflight_provenance( + inbox, ws, setup.solar_url, target_rgb, setup.show_token) + if rc != 0: + return 1 + + if preflight_only: + print("[M8] --preflight-only set: skipping broadcast.") + return 0 + + # Broadcast core reused verbatim from M6. m6.broadcast now goes live + # through start_destination_with_retry (the bounded anti-boot-race + # StartDestination retry ported from probe-twitch-live.py), so this + # M8 path is robust to the post-scene-switch boot race observed in CI. + print("\n[M8] going live to Twitch (provenance proven) ...") + m6.LIVE_VOD_DIR = LIVE_VOD_DIR + return await m6.broadcast(inbox, ws, stream_key, duration_sec, pulsar) + + +def pick_free_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + finally: + s.close() + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Pulsar M8 Canvas+Blue authored scene -> Solar LSDP -> Twitch probe") + ap.add_argument("--exe", type=pathlib.Path, + default=pathlib.Path(os.environ.get("PULSAR_EXE", str(m6.DEFAULT_EXE))), + help="path to pulsar.exe (default: built rundir)") + ap.add_argument("--gateway-url", type=str, + default=os.environ.get("M8_GATEWAY_URL", "http://127.0.0.1:8099"), + help="ZabGate base URL (default: the tunnel'd gateway). All " + "SETUP HTTP is gateway-first against this base.") + ap.add_argument("--solar-version", type=str, + default=os.environ.get("M8_SOLAR_VERSION", + m8_setup.DEFAULT_SOLAR_VERSION), + help="Solar bundle version served at /static/solar/v{N}/ " + "(default 0.2.0 — the LSDP wire).") + ap.add_argument("--show-stream-path", type=str, + default=os.environ.get("M8_SHOW_STREAM_PATH", + m8_setup.DEFAULT_SHOW_STREAM_PATH), + choices=["stream.lsdp", "stream"], + help="show-stream wire path: stream.lsdp (LSDP, default, " + "needs Orion in dual/lsdp mode) or stream (bespoke " + "fallback).") + ap.add_argument("--duration", type=int, + default=int(os.environ.get("LIVE_TEST_DURATION", "30")), + help="broadcast duration in seconds (default 30)") + ap.add_argument("--fps", type=int, + default=int(os.environ.get("LIVE_TEST_FPS", "60")), + help="encoder fps target (default 60)") + ap.add_argument("--preflight-only", action="store_true", + help="author+push+activate+prove, but do NOT broadcast") + ap.add_argument("--ready-timeout", type=float, default=m6.READY_TIMEOUT_S) + args = ap.parse_args() + + exe: pathlib.Path = args.exe + if not exe.exists(): + print(f"error: pulsar.exe not found at {exe}") + print("Build it first: scripts/build-win.ps1 -Full") + return 2 + + # CC-1: the SETUP operator credential is a dedicated short-TTL admin + # token from étage-1 — NOT ORION_OPERATOR_TOKEN (the long-lived service + # mint). We read M8_OPERATOR_TOKEN ONLY and refuse to fall back. + operator_token = os.environ.get("M8_OPERATOR_TOKEN", "").strip() + if not operator_token: + print("error: M8_OPERATOR_TOKEN env var is empty (required). It is the " + "admin/operator JWT for the SETUP leg (save/push/active-scene/mint). " + "Source it from the étage-1 secret as a SHORT-TTL admin token — do " + "NOT reuse ORION_OPERATOR_TOKEN (Bastion CC-1). Never commit it.") + return 2 + if operator_token == os.environ.get("ORION_OPERATOR_TOKEN", "").strip() and operator_token: + print("error: M8_OPERATOR_TOKEN must NOT equal ORION_OPERATOR_TOKEN " + "(the long-lived exp-2027 service token) — Bastion CC-1. Mint a " + "dedicated short-TTL admin token for the test run.") + return 2 + + stream_key = os.environ.get("TWITCH_STREAM_KEY", "").strip() + if not args.preflight_only and not stream_key: + print("error: TWITCH_STREAM_KEY env var is empty (required unless " + "--preflight-only). Set it from the étage-1 secret; never commit.") + return 2 + + print("=== M8 SETUP leg (gateway-first authoring + activation) ===") + print(f" gateway: {args.gateway_url}") + print(f" wire: {args.show_stream_path} solar: v{args.solar_version}") + print(f" M8_OPERATOR_TOKEN: ") + print(f" TWITCH_STREAM_KEY: {'' if stream_key else ''}") + + try: + setup = m8_setup.run_setup( + gateway_url=args.gateway_url, + operator_token=operator_token, + twitch_key=stream_key, + solar_version=args.solar_version, + show_stream_path=args.show_stream_path, + log=print, + ) + except m8_setup.SetupError as exc: + # SetupError messages are already redacted at construction. + print(f"FAIL: SETUP leg failed: {exc}") + return 1 + + print("=== M8 SETUP done — scene authored, pushed, active, token minted ===") + print(f" scene_id={setup.scene_id}") + print(f" bundle_hash(H)={setup.bundle_hash}") + print(f" pushed_scene_version={setup.pushed_scene_version}") + print(f" blueprints={setup.blueprint_ids}") + print(f" background(provenance target)={setup.test_background}") + print(f" solar_url={m8_setup.redact_solar_url(setup.solar_url)}") + + # Secrets to scrub from EVERY diagnostic line below (M6's redact() only + # covered the stream key; M8 adds the operator JWT + show-token). + scrub = [s for s in (stream_key, operator_token, setup.show_token) if s] + + def safe(text: str) -> str: + return m8_setup.redact_token(text, *scrub) + + port = pick_free_port() + password = secrets.token_urlsafe(16) + print(f"\n=== M8 PRE-FLIGHT + BROADCAST (reused M6 core) ===") + print(f"spawning: {exe}") + print(f" PULSAR_PORT={port} PULSAR_PASSWORD=") + + # Point the reused M6 core at the M8 artefact paths + destination name + # BEFORE spawn (PulsarProcess.spawn reads LIVE_VOD_DIR for PULSAR_RECORD_DIR). + m6.LIVE_VOD_DIR = LIVE_VOD_DIR + m6.PROOF_PNG = PROOF_PNG + m6.BUILD_DIR = BUILD_DIR + m6.DESTINATION_NAME = "pulsar-m8-canvas-live" + pulsar = m6.PulsarProcess(exe, port, password, args.fps) + rc = 1 + try: + pulsar.spawn() + ws_url, sentinel_pw = pulsar.wait_ready(args.ready_timeout) + print(f"READY: {ws_url}") + rc = asyncio.run(run( + ws_url, sentinel_pw, setup, stream_key, + args.duration, args.preflight_only, pulsar, + )) + except KeyboardInterrupt: + print("interrupted") + rc = 130 + except Exception as exc: # noqa: BLE001 — top-level probe diagnostic + print(f"FAIL: {safe(str(exc))}") + if pulsar.proc is not None: + print(safe(pulsar.diag())) + rc = 1 + finally: + if rc != 0: + tail = pulsar.lines[-80:] + if tail: + print("\n---- pulsar stdout (last 80 lines, redacted) ----") + for ln in tail: + print(f" {safe(ln)}") + print("---- end pulsar stdout ----\n") + pulsar.shutdown() + if pulsar.proc is not None and pulsar.proc.poll() is None: + print("error: pulsar.exe still running after shutdown attempt") + rc = rc or 1 + else: + print("pulsar.exe reaped cleanly") + + print("PASS" if rc == 0 else (f"SKIPPED (exit {rc})" if rc == 3 + else f"FAILED (exit {rc})")) + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/run-m8.ps1 b/scripts/run-m8.ps1 new file mode 100644 index 0000000..5108c6b --- /dev/null +++ b/scripts/run-m8.ps1 @@ -0,0 +1,118 @@ +# run-m8.ps1 — wrapper for the M8 Canvas-authored live probe with a hard +# secret grep-assert (Bastion PV-1 / CC-1, ADR Pulsar-002 §A1.5 criterion 7). +# +# It runs scripts/probe-m8-canvas-live.py, tees stdout to a log, then +# grep-asserts that NONE of the three credentials (the operator JWT, the +# minted show-token, the Twitch stream key) appear in clear in the captured +# stdout OR in the produced proof PNG / VOD artefacts. A leak fails the run +# (exit 1) REGARDLESS of the probe's own exit code — redaction is not a +# best-effort log nicety here, it is a gate. +# +# The credentials come from the étage-1 environment (M8_OPERATOR_TOKEN, +# TWITCH_STREAM_KEY); the show-token is minted at runtime. Reading the minted +# token back out of the probe to scan for it is not possible from here, so we +# instead assert the KNOWN secrets never appear, and that no raw +# `?token=` or `token%3DeyJ` substring survives in the log. +# +# Usage (from the repo root): +# $env:M8_OPERATOR_TOKEN = (Get-Content ..\.env.m8 | ...) # étage-1 +# $env:TWITCH_STREAM_KEY = "..." # étage-1 +# $env:M8_GATEWAY_URL = "http://127.0.0.1:8099" +# pwsh scripts/run-m8.ps1 -PreflightOnly +# pwsh scripts/run-m8.ps1 # + broadcast + +[CmdletBinding()] +param( + [switch] $PreflightOnly, + [string] $GatewayUrl = $env:M8_GATEWAY_URL, + [string] $ShowStreamPath = "stream.lsdp", + [string] $SolarVersion = "0.2.0", + [string] $PythonExe = "python" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$stdoutLog = Join-Path $repoRoot "build\m8-probe-stdout.log" +$proofPng = Join-Path $repoRoot "build\m8-canvas-scene.png" +$vodDir = Join-Path $repoRoot "build\m8-canvas-vod" + +New-Item -ItemType Directory -Force -Path (Join-Path $repoRoot "build") | Out-Null + +# --- Build the probe argument list --------------------------------------- +$probeArgs = @("scripts/probe-m8-canvas-live.py") +if ($PreflightOnly) { $probeArgs += "--preflight-only" } +if ($GatewayUrl) { $probeArgs += @("--gateway-url", $GatewayUrl) } +$probeArgs += @("--show-stream-path", $ShowStreamPath) +$probeArgs += @("--solar-version", $SolarVersion) + +# --- Pre-flight env sanity (fail fast, never echo the values) ------------ +if (-not $env:M8_OPERATOR_TOKEN) { + Write-Error "M8_OPERATOR_TOKEN is not set (étage-1 admin short-TTL JWT). Refusing to run." +} +if (-not $PreflightOnly -and -not $env:TWITCH_STREAM_KEY) { + Write-Error "TWITCH_STREAM_KEY is not set (étage-1) and not --PreflightOnly. Refusing to run." +} + +# --- Run the probe, tee stdout ------------------------------------------- +Push-Location $repoRoot +try { + Write-Host "[run-m8] launching probe (wire=$ShowStreamPath solar=v$SolarVersion preflight-only=$PreflightOnly)" + & $PythonExe @probeArgs 2>&1 | Tee-Object -FilePath $stdoutLog + $probeExit = $LASTEXITCODE +} finally { + Pop-Location +} + +# --- Grep-assert: no credential in clear in stdout or artefacts ----------- +$secrets = @() +if ($env:M8_OPERATOR_TOKEN) { $secrets += $env:M8_OPERATOR_TOKEN } +if ($env:TWITCH_STREAM_KEY) { $secrets += $env:TWITCH_STREAM_KEY } + +$leak = $false + +function Assert-NoSecret { + param([string] $Path, [string[]] $Needles, [string] $Label) + if (-not (Test-Path $Path)) { return $false } + $bytes = [System.IO.File]::ReadAllBytes($Path) + $text = [System.Text.Encoding]::UTF8.GetString($bytes) + foreach ($n in $Needles) { + if ($n -and $text.Contains($n)) { + Write-Host "::error::SECRET LEAK — a credential appears in clear in $Label ($Path)" + return $true + } + } + return $false +} + +# 1. The known secrets (operator JWT, Twitch key) must not appear anywhere. +$leak = (Assert-NoSecret -Path $stdoutLog -Needles $secrets -Label "probe stdout") -or $leak +$leak = (Assert-NoSecret -Path $proofPng -Needles $secrets -Label "proof PNG") -or $leak + +# 2. Heuristic: the minted show-token is unknown to this wrapper, so assert +# no un-redacted JWT-shaped token survived in the log — neither a plain +# `token=eyJ...` nor a url-encoded `token%3DeyJ...`. Redaction replaces +# both with ``, so any surviving `eyJ` after `token` is a leak. +if (Test-Path $stdoutLog) { + $log = Get-Content -Raw -Path $stdoutLog + if ($log -match "token=eyJ" -or $log -match "token%3DeyJ") { + Write-Host "::error::SECRET LEAK — an un-redacted show-token (token=eyJ / token%3DeyJ) survived in the probe stdout" + $leak = $true + } +} + +# 3. Scan VOD artefacts too (defensive; the MP4 is media, not text, but a +# container metadata leak would surface as a substring). +if (Test-Path $vodDir) { + Get-ChildItem -Path $vodDir -File -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + $leak = (Assert-NoSecret -Path $_.FullName -Needles $secrets -Label "VOD artefact") -or $leak + } +} + +if ($leak) { + Write-Host "[run-m8] GREP-ASSERT FAILED — credential leak detected; failing the run regardless of probe exit ($probeExit)." + exit 1 +} + +Write-Host "[run-m8] grep-assert clean — no credential leaked to stdout / PNG / VOD." +Write-Host "[run-m8] probe exit code: $probeExit" +exit $probeExit