From d0fe82507873c07d85d3455e6ba56721a57f10bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:34:12 +0200 Subject: [PATCH 1/5] test(m8): add deterministic Canvas+Blue scene fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked-in fixtures for the M8 Canvas-authored live test: two distinct, pure Blue blueprints (score: literal+literal -> math.add -> output leaf `value`; timer: literal -> output leaf `value`) and a rich multi-component LSML 1.1 scene bundle. The scene binds both blueprints by scene-local key via hand-prefixed bindings (`bind:{value:"score.value"}` / `"timer.value"`, ADR Orion-001 §3.3), over a full-frame solid background of a known unusual colour (#1A9E57) that the pre-flight modal-colour provenance check ties the on-air pixels to. The bundle carries its own content-address scene_version (sha256:c2e528...) computed the same way lumencast-go/lsml.HashBundle does, so Canvas's A0 store address-check passes and /layouts/{H} resolves. N >= 2 distinct blueprint ids + distinct keys; computes are core.* pure so Orion's compile is clean by construction (no IMPURE_COMPUTE/COMPILE_FAILED). Refs #44 #45 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/fixtures/m8-blueprint-score.json | 54 ++++++++++++++++++++++++ scripts/fixtures/m8-blueprint-timer.json | 36 ++++++++++++++++ scripts/fixtures/m8-scene.lsml.json | 44 +++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 scripts/fixtures/m8-blueprint-score.json create mode 100644 scripts/fixtures/m8-blueprint-timer.json create mode 100644 scripts/fixtures/m8-scene.lsml.json 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 } + } + ] + } + ] + } +} From c4aabe4d12af027d3b8c72cf6a46a3b8c80739c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:34:27 +0200 Subject: [PATCH 2/5] feat(m8): add Canvas-authored live probe with provenance + LSDP wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit probe-m8-canvas-live.py authors a multi-blueprint Canvas+Blue scene, pushes it through the real Orion compile, drives the active-scene (M8 is the FIRST real driver of POST /orion/api/v1/show/active-scene), mints a fresh viewer show-token, and runs the proven M6 pre-flight + broadcast core against the Solar v0.2.0 LSDP URL it composed itself — then PROVES the on-air frame is that authoring. SETUP leg (m8_setup.py, gateway-first HTTP, stdlib-only): - ensure N Blue blueprints (idempotent by slug, published) - PUT the LSML bundle to Canvas A0, save + push (lsml_bundle_hash=H) - active-scene + round-trip GET /show (active_scene_id == scene_id) - mint show-token, compose Solar v0.2.0 LSDP URL (broadcast-url.ts parity) Provenance: (3) server-side round-trip active_scene_id == scene_id + the captured push scene_version, and (1) the captured CEF frame's modal colour ≈ the authored background (#1A9E57) — binds server state to pixels. Blank / wrong-colour => NO GO (no broadcast). Reuses M6's CEF spawn/reap, PNG decode, analyse_frame, RTMP metrics + bounded StartDestination retry by importing probe-m6-live.py; extends the non-blank predicate with the modal-colour assertion. Security (Bastion PV-1/CC-1): zero token committed (no baked-JWT DEFAULT_SOLAR_URL — the M6 trap); redactSolarUrl ported to Python and applied to every solar_url line; operator JWT is M8_OPERATOR_TOKEN (short-TTL admin, étage-1) and is hard-refused if equal to ORION_OPERATOR_TOKEN; run-m8.ps1 grep-asserts no credential leaks to stdout/PNG/VOD and fails the run on a leak regardless of probe exit. A gated, non-blocking CI job mirrors the M6 shape (typed skip exit 3). Wire = LSDP by default (--show-stream-path stream.lsdp), bespoke `stream` kept as a supported fallback. Refs #44 #46 #47 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/pipeline.yml | 75 +++++ scripts/m8_setup.py | 508 ++++++++++++++++++++++++++++++++ scripts/probe-m8-canvas-live.py | 383 ++++++++++++++++++++++++ scripts/run-m8.ps1 | 118 ++++++++ 4 files changed, 1084 insertions(+) create mode 100644 scripts/m8_setup.py create mode 100644 scripts/probe-m8-canvas-live.py create mode 100644 scripts/run-m8.ps1 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/m8_setup.py b/scripts/m8_setup.py new file mode 100644 index 0000000..031e6ba --- /dev/null +++ b/scripts/m8_setup.py @@ -0,0 +1,508 @@ +#!/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: + _, show = self._request("GET", "/orion/api/v1/show", auth=False, 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-m8-canvas-live.py b/scripts/probe-m8-canvas-live.py new file mode 100644 index 0000000..2e87757 --- /dev/null +++ b/scripts/probe-m8-canvas-live.py @@ -0,0 +1,383 @@ +#!/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 +StartDestination retry (bounded anti-boot-race) is reused from +probe-twitch-live.py's pattern. + +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 (which itself reuses the + # probe-twitch-live.py bounded StartDestination retry pattern). + 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=") + + m6.LIVE_VOD_DIR = LIVE_VOD_DIR + 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..7f6bcd4 --- /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, so we scan for it +# by reading it back out of the probe's own redaction is NOT possible — 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 From 41215d984db9656160aa06a9ca3268b4d618c948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:38:03 +0200 Subject: [PATCH 3/5] fix(m8): point reused M6 core at M8 artefact paths + destination name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Override m6.LIVE_VOD_DIR / PROOF_PNG / BUILD_DIR / DESTINATION_NAME before spawn so the reused broadcast core writes the M8 proof PNG, VOD, and uses an M8-specific Twitch destination name (clean teardown, no M6/M8 artefact collision). PulsarProcess.spawn reads LIVE_VOD_DIR for PULSAR_RECORD_DIR, so the override must precede spawn — it now does. Refs #46 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/probe-m8-canvas-live.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/probe-m8-canvas-live.py b/scripts/probe-m8-canvas-live.py index 2e87757..6414d37 100644 --- a/scripts/probe-m8-canvas-live.py +++ b/scripts/probe-m8-canvas-live.py @@ -340,7 +340,12 @@ def safe(text: str) -> str: 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: From f2c873b58d8578a3fad5e499a0fe8c2ed32fb2af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:51:41 +0200 Subject: [PATCH 4/5] fix(probe): wire bounded anti-boot-race StartDestination retry on M8 broadcast Vigil request-changes: the M8 docstring claimed the broadcast leg reused the bounded anti-boot-race StartDestination retry, but m6.broadcast (reused verbatim by M8) did a single-shot StartDestination. The post-scene-switch boot race was observed flaking in CI, so make the claim true by adding the robustness, not by softening the doc. Factor start_destination_with_retry() into probe-m6-live.py (the shared broadcast core M8 imports), ported verbatim from probe-twitch-live.py's START_DEST_* pattern: budget 20s, cadence 1s, retry ONLY on the exact transient 'frontend streaming output unavailable' error, hard-fail on any other error or on an exhausted budget, zero masking. m6.broadcast now goes live through it, so the M8 broadcast path inherits the retry with no fork of the broadcast loop. Correct the M8 docstring + the broadcast comment to describe what is actually wired, and fix the truncated grep-assert comment in run-m8.ps1 (cosmetic). Redaction / grep-assert / provenance / legs left untouched. Refs #46 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/probe-m6-live.py | 66 ++++++++++++++++++++++++++++----- scripts/probe-m8-canvas-live.py | 14 +++++-- scripts/run-m8.ps1 | 4 +- 3 files changed, 69 insertions(+), 15 deletions(-) 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 index 6414d37..701bdac 100644 --- a/scripts/probe-m8-canvas-live.py +++ b/scripts/probe-m8-canvas-live.py @@ -28,8 +28,12 @@ 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 -StartDestination retry (bounded anti-boot-race) is reused from -probe-twitch-live.py's pattern. +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 @@ -222,8 +226,10 @@ async def run(ws_url: str, password: str, setup: "m8_setup.SetupResult", print("[M8] --preflight-only set: skipping broadcast.") return 0 - # Broadcast core reused verbatim from M6 (which itself reuses the - # probe-twitch-live.py bounded StartDestination retry pattern). + # 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) diff --git a/scripts/run-m8.ps1 b/scripts/run-m8.ps1 index 7f6bcd4..5108c6b 100644 --- a/scripts/run-m8.ps1 +++ b/scripts/run-m8.ps1 @@ -9,8 +9,8 @@ # 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, so we scan for it -# by reading it back out of the probe's own redaction is NOT possible — we +# 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. # From 971288f22fc4ffbafc2af71cda6b519db6c1ce1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:59:17 +0200 Subject: [PATCH 5/5] fix(m8): send operator Bearer on S7 GET /orion/show The S7 provenance round-trip (get_show) called GET /orion/api/v1/show with auth=False. On the public gateway (zabgate.cyell.dev) this endpoint is operator-gated: 401 without a Bearer, 200 with the operator Bearer (verified by Keeper). So S7 raised SetupError(401) before pre-flight and the SETUP never reached broadcast. Flip get_show to auth=True so it rides the operator JWT like every other SETUP leg; expect=(200,) unchanged. Audited all SETUP legs: S5 push and S6 active-scene are operator-gated mutations and already auth=True (the _request default); the LSDP WS URL keeps the viewer show-token in ?token= and never carries the operator Bearer. S7 was the only leg with the bug. Refs #45 #46 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/m8_setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/m8_setup.py b/scripts/m8_setup.py index 031e6ba..e7953fa 100644 --- a/scripts/m8_setup.py +++ b/scripts/m8_setup.py @@ -340,7 +340,10 @@ def set_active_scene(self, scene_id: str) -> None: ) def get_show(self) -> dict: - _, show = self._request("GET", "/orion/api/v1/show", auth=False, expect=(200,)) + # 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 ---------------------------------------------------------