From eec860f71bec6ea97cda2e39f7ad5b4d2a328c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?ClodoCap=C3=A9o?= <159788250+ClodoCapeo@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:04:22 +0200 Subject: [PATCH 01/12] test(probe): add 30s Twitch scene-switch probe with zero PC audio Add scripts/probe-twitch-scene-switch.py: a 30s live Twitch broadcast that performs a real mid-course scene change at duration/2 (~t=15s) and carries a silent AAC track (no desktop/mic/process audio). Switch implementation prefers a true two-scene OBS switch (path a): CreateScene + SetCurrentProgramScene, viable headless because obs-websocket resolves scenes via libobs-global name lookup and the frontend-stub rebinds output channel 0 on set_current_scene. Auto-falls-back to a SetCaptureSource URL swap (path b) when the headless build does not honour the program-scene flip; the chosen path is asserted and reported. Zero PC sound is enforced on three fronts: mic/process-audio env popped at spawn (sources never created), browser_source created with reroute_audio=False, and the frontend-stub's unconditional PulsarDesktopAudio loopback muted over the wire with GetInputList/GetInputMute asserting no unmuted audio input remains before going live. Two dependency-free, silent scene pages (scene-a.html cobalt / scene-b.html crimson) make the switch unmistakable on the VOD. Stream key read from TWITCH_STREAM_KEY only, redacted in all logs. Refs: probe-twitch-live.py, probe-m6-live.py (v5 plumbing reused) Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/live-test/scene-a.html | 59 ++ scripts/live-test/scene-b.html | 55 ++ scripts/probe-twitch-scene-switch.py | 856 +++++++++++++++++++++++++++ 3 files changed, 970 insertions(+) create mode 100644 scripts/live-test/scene-a.html create mode 100644 scripts/live-test/scene-b.html create mode 100644 scripts/probe-twitch-scene-switch.py diff --git a/scripts/live-test/scene-a.html b/scripts/live-test/scene-a.html new file mode 100644 index 0000000..dea3bd4 --- /dev/null +++ b/scripts/live-test/scene-a.html @@ -0,0 +1,59 @@ + + + + +Pulsar — SCENE A + + + + +
+
SCENE A
+
COBALT · PULSAR
+
LIVE
+
+ + diff --git a/scripts/live-test/scene-b.html b/scripts/live-test/scene-b.html new file mode 100644 index 0000000..fc4eec9 --- /dev/null +++ b/scripts/live-test/scene-b.html @@ -0,0 +1,55 @@ + + + + +Pulsar — SCENE B + + + + +
+
SCENE B
+
CRIMSON · PULSAR
+
LIVE
+
+ + diff --git a/scripts/probe-twitch-scene-switch.py b/scripts/probe-twitch-scene-switch.py new file mode 100644 index 0000000..53c5bec --- /dev/null +++ b/scripts/probe-twitch-scene-switch.py @@ -0,0 +1,856 @@ +#!/usr/bin/env python3 +""" +Pulsar live Twitch SCENE-SWITCH probe -- a 30s broadcast with a real scene +change at mid-course (~t=15s) and ZERO PC sound. + +WHAT THIS PROVES + A live Twitch push that, at duration/2, performs a genuine scene change so a + reviewer scrubbing the VOD sees a hard cut (cobalt-blue "SCENE A" -> crimson + "SCENE B"). Two visually-distinct, dependency-free, SILENT HTML pages are + served locally (scripts/live-test/scene-a.html + scene-b.html) and rendered + by Pulsar's CEF browser_source. + +SWITCH IMPLEMENTATION -- (a) real OBS scene, with (b) auto-fallback + The headless frontend-stub (plugins/pulsar-frontend-stub) creates exactly ONE + program scene at boot ("Default") and its `scenes` vector is hard-coded to a + single entry. BUT: + * obs-websocket's CreateScene uses obs_canvas_scene_create on the MAIN + canvas, registering the scene in libobs's GLOBAL source table. + * SetCurrentProgramScene -> AcquireScene -> obs_get_source_by_name resolves + by libobs-global name (NOT via the stub's scenes vector), so a + CreateScene'd scene IS found. + * The stub's obs_frontend_set_current_scene rebinds obs_set_output_source(0, + scene) and emits SCENE_CHANGED -> the new scene actually composites. + So a TRUE two-scene switch is viable headless and is attempted FIRST (path a): + 1. CreateScene("pulsar-scene-b"). + 2. SetCurrentProgramScene("pulsar-scene-b"); SetCaptureSource(scene-b.html) + -- the pulsar-scene plugin installs the browser_source on whatever + obs_frontend_get_current_scene() returns, i.e. scene-b. + 3. SetCurrentProgramScene("Default" / scene-a); SetCaptureSource(scene-a.html) + -- scene-a now carries the cobalt page; we go live on it. + 4. At t=duration/2: SetCurrentProgramScene("pulsar-scene-b") -> hard cut to + crimson. Asserted via GetCurrentProgramScene before AND after. + If CreateScene or the program-scene flip is not honoured by this build + (older stub, single-canvas quirk), the probe AUTO-FALLS-BACK to path (b): + a single program scene whose displayed browser_source URL is swapped from + scene-a.html to scene-b.html via SetCaptureSource at t=duration/2. Path (b) + is still a real, eye-visible content change on the live wire; it is clearly + labelled in the run log + the run summary as a fallback. The chosen path is + reported so Vigil/Probe know which assertion ran. + +ZERO PC SOUND (impératif) + "No sound from the PC" is enforced on three fronts: + 1. Spawn env: PULSAR_MIC_DEVICE_ID popped (mic source never created) and + PULSAR_PROCESS_AUDIO_NAME left unset (process-loopback never created). + 2. The browser_source is created with reroute_audio=False -- the page audio + (there is none anyway; both scene pages are silent) is NOT routed into + the OBS mixer. + 3. The frontend-stub ALWAYS creates a desktop-audio loopback on mixer + channel 1 ("PulsarDesktopAudio", device_id=default) at boot -- THAT is + literally "the sound of the PC". The probe SetInputMute()s it right after + auth, verifies the mute via GetInputMute, then enumerates GetInputList + + GetInputMute over every audio input and ASSERTS none remains unmuted + before going live. If any unmuted audio input survives, the probe fails + closed and does NOT broadcast. + Net: the AAC track on the Twitch push is silence. + +SECRET HANDLING + TWITCH_STREAM_KEY is read from the environment ONLY (set by the caller from + the etage-1 secret file). It is NEVER printed/logged/written; any log line + that might echo it is redacted to (redact(), mirrored from + probe-m6-live.py). Missing key -> exit 2, no broadcast. + +LICENSE INVARIANT + Pure obs-websocket v5 over the process boundary (+ `pulsar`/`pulsar-scene` + vendor requests). No FFI, no native import. CEF lives entirely inside the + pulsar.exe process tree. + +Usage (from the repo root, against a built rundir): + pip install websockets + export TWITCH_STREAM_KEY=... # from etage-1 secret, NEVER committed + python scripts/probe-twitch-scene-switch.py + python scripts/probe-twitch-scene-switch.py --duration 30 + +Required env: + TWITCH_STREAM_KEY Twitch stream key (opaque; never logged) + +Optional env / flags: + PULSAR_EXE / --exe override pulsar.exe path + --duration broadcast seconds (default 30); switch fires at /2 + --fps encoder fps target (default 60) + --force-fallback skip path (a), go straight to the URL-swap fallback (b) + +Exit codes: + 0 pass (scene switch performed + asserted; zero unmuted audio confirmed) + 1 fail (switch not honoured / broadcast assertion failed) + 2 config error (no key, no exe, bad args) + 3 typed skip (browser_source not registered -- LIGHT build, needs -Full) +""" +from __future__ import annotations + +import argparse +import asyncio +import base64 +import functools +import hashlib +import http.server +import json +import os +import pathlib +import re +import secrets +import socket +import socketserver +import subprocess +import sys +import threading +import time +from typing import Callable, Optional + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] + except Exception: + pass + +try: + import websockets +except ImportError: + print("error: pip install websockets (pure WS client — no native deps)") + sys.exit(2) + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +DEFAULT_EXE = ( + REPO_ROOT / "upstream" / "build_x64" / "rundir" / "RelWithDebInfo" + / "bin" / "64bit" / "pulsar.exe" +) +SCENE_DIR = REPO_ROOT / "scripts" / "live-test" +BUILD_DIR = REPO_ROOT / "build" +LIVE_VOD_DIR = BUILD_DIR / "scene-switch-vod" + +READY_TIMEOUT_S = 60.0 +SHUTDOWN_GRACE_S = 8.0 +EVENT_SUBSCRIPTION_ALL = 0x7FF + +CANVAS_W = 1920 +CANVAS_H = 1080 + +FRAME_DROP_RATIO_MAX = 0.05 +POLL_INTERVAL_SEC = 5.0 +DESTINATION_NAME = "pulsar-scene-switch" + +# The default program scene the frontend-stub creates at boot. +DEFAULT_SCENE_NAME = "Default" +# The second program scene we attempt to create for path (a). +SCENE_B_NAME = "pulsar-scene-b" +# Desktop-audio source the frontend-stub binds to mixer channel 1 at boot. +# This is the "sound of the PC" we must mute. Name from +# plugins/pulsar-frontend-stub/src/pulsar-frontend-stub.cpp. +DESKTOP_AUDIO_SOURCE = "PulsarDesktopAudio" + +BENIGN_LOG_SUBSTRINGS = [ + "no target (set PULSAR_CAPTURE_WINDOW)", + "Failed to find module 'win-mf'", +] + + +# -------------------------------------------------------------------------- +# Secret redaction. The stream key must never reach a log line. +# -------------------------------------------------------------------------- +def redact(text: str, key: str) -> str: + if key and key in text: + return text.replace(key, "") + return text + + +# -------------------------------------------------------------------------- +# Local HTTP server hosting the two scene pages. +# -------------------------------------------------------------------------- +def find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args) -> None: # silence per-request stderr noise + pass + + +def start_scene_server(port: int) -> socketserver.ThreadingTCPServer: + handler = functools.partial(_QuietHandler, directory=str(SCENE_DIR)) + socketserver.ThreadingTCPServer.allow_reuse_address = True + httpd = socketserver.ThreadingTCPServer(("127.0.0.1", port), handler) + threading.Thread(target=httpd.serve_forever, name="scene-http", daemon=True).start() + return httpd + + +# -------------------------------------------------------------------------- +# Process management -- mirrors probe-m6-live.py PulsarProcess. +# -------------------------------------------------------------------------- +READY_RE = re.compile(r"^PULSAR_READY ws=(\S+) password=(\S+)$") + + +class PulsarProcess: + def __init__(self, exe: pathlib.Path, port: int, password: str, fps: int) -> None: + self.exe = exe + self.port = port + self.password = password + self.fps = fps + self.proc: Optional[subprocess.Popen] = None + self._lines: list[str] = [] + self._ready_event = threading.Event() + self._ready_match: Optional[re.Match[str]] = None + + def spawn(self) -> None: + env = dict(os.environ) + env["PULSAR_PORT"] = str(self.port) + env["PULSAR_PASSWORD"] = self.password + env["PULSAR_FPS"] = str(self.fps) + env["PULSAR_RESOLUTION"] = f"{CANVAS_W}x{CANVAS_H}" + env["PULSAR_VIDEO_BITRATE"] = "6000" + LIVE_VOD_DIR.mkdir(parents=True, exist_ok=True) + env["PULSAR_RECORD_DIR"] = str(LIVE_VOD_DIR) + + # ZERO PC SOUND, part 1: never wire a window capture, never wire mic, + # never wire process-loopback audio. The frontend-stub only creates the + # mic source if PULSAR_MIC_DEVICE_ID is set, and process audio only if + # PULSAR_PROCESS_AUDIO_NAME is set -- so popping/leaving them unset means + # those sources are never created. Desktop audio is still created + # unconditionally and is muted later over the wire (see ensure_silence). + env.pop("PULSAR_CAPTURE_WINDOW", None) + env.pop("PULSAR_MIC_DEVICE_ID", None) + env.pop("PULSAR_PROCESS_AUDIO_NAME", None) + + creationflags = 0 + if os.name == "nt": + creationflags = 0x08000000 # CREATE_NO_WINDOW + + # --disable-gpu / --no-sandbox: forwarded to CEF via GetCommandLineW(). + # SW rasterization is the canonical headless-CEF config (same as M4/M6). + self.proc = subprocess.Popen( + [str(self.exe), "--disable-gpu", "--no-sandbox"], + cwd=str(self.exe.parent), # libobs resolves data/ from cwd + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, + bufsize=1, + text=True, + encoding="utf-8", + errors="replace", + creationflags=creationflags, + ) + threading.Thread(target=self._pump_stdout, daemon=True).start() + + def _pump_stdout(self) -> None: + assert self.proc is not None and self.proc.stdout is not None + for line in self.proc.stdout: + line = line.rstrip("\r\n") + self._lines.append(line) + m = READY_RE.match(line) + if m is not None and not self._ready_event.is_set(): + self._ready_match = m + self._ready_event.set() + + def wait_ready(self, timeout: float) -> tuple[str, str]: + deadline = time.monotonic() + timeout + while True: + if self._ready_event.wait(timeout=0.2): + m = self._ready_match + assert m is not None + return m.group(1), m.group(2) + assert self.proc is not None + if self.proc.poll() is not None: + raise RuntimeError( + f"pulsar.exe exited (code {self.proc.returncode}) before READY.\n" + + self._diag() + ) + if time.monotonic() >= deadline: + raise RuntimeError( + f"pulsar.exe did not signal READY within {timeout:.0f}s.\n" + + self._diag() + ) + + @property + def lines(self) -> list[str]: + return list(self._lines) + + def diag(self) -> str: + return self._diag() + + def _diag(self) -> str: + tail = self._lines[-40:] + body = "\n".join(f" | {ln}" for ln in tail) if tail else " | (no output)" + return f"--- pulsar stdout/stderr (last {len(tail)} lines) ---\n{body}" + + def shutdown(self, grace: float = SHUTDOWN_GRACE_S) -> None: + if self.proc is None or self.proc.poll() is not None: + return + try: + self.proc.terminate() + except Exception: + pass + try: + self.proc.wait(timeout=grace) + return + except Exception: + pass + try: + self.proc.kill() + self.proc.wait(timeout=grace) + except Exception: + pass + + +# -------------------------------------------------------------------------- +# obs-websocket v5 plumbing -- mirrors probe-m6-live.py. +# -------------------------------------------------------------------------- +def compute_auth(password: str, salt: str, challenge: str) -> str: + secret = base64.b64encode( + hashlib.sha256((password + salt).encode("utf-8")).digest() + ).decode("ascii") + return base64.b64encode( + hashlib.sha256((secret + challenge).encode("utf-8")).digest() + ).decode("ascii") + + +class Inbox: + def __init__(self) -> None: + self.events: list[dict] = [] + self.responses: list[dict] = [] + + async def pump(self, ws, until: Callable[["Inbox"], bool], timeout: float) -> None: + end = asyncio.get_event_loop().time() + timeout + while not until(self): + remaining = end - asyncio.get_event_loop().time() + if remaining <= 0: + raise asyncio.TimeoutError + raw = await asyncio.wait_for(ws.recv(), timeout=remaining) + msg = json.loads(raw) + op = msg.get("op") + if op == 5: + self.events.append(msg["d"]) + elif op == 7: + self.responses.append(msg["d"]) + + +async def request( + inbox: Inbox, ws, request_type: str, request_id: str, + data: dict | None = None, timeout: float = 30.0, +) -> dict: + body: dict = {"requestType": request_type, "requestId": request_id} + if data is not None: + body["requestData"] = data + await ws.send(json.dumps({"op": 6, "d": body})) + + def has_response(ix: Inbox) -> bool: + return any(r["requestId"] == request_id for r in ix.responses) + + await inbox.pump(ws, has_response, timeout) + for i, r in enumerate(inbox.responses): + if r["requestId"] == request_id: + return inbox.responses.pop(i) + raise RuntimeError("unreachable") + + +async def vendor_call( + inbox: Inbox, ws, request_id: str, vendor: str, vendor_type: str, + data: dict | None = None, timeout: float = 30.0, +) -> dict: + return await request(inbox, ws, "CallVendorRequest", request_id, { + "vendorName": vendor, + "requestType": vendor_type, + "requestData": data or {}, + }, timeout) + + +def vendor_response_data(resp: dict) -> dict: + rd = resp.get("responseData", {}) + inner = rd.get("responseData", {}) if isinstance(rd, dict) else {} + return inner if isinstance(inner, dict) else {} + + +def vendor_request_status(resp: dict) -> dict: + s = resp.get("requestStatus", {}) + return s if isinstance(s, dict) else {} + + +def req_ok(resp: dict) -> bool: + return bool(resp.get("requestStatus", {}).get("result")) + + +# -------------------------------------------------------------------------- +# ZERO PC SOUND: mute the desktop-audio loopback + assert no unmuted input. +# -------------------------------------------------------------------------- +async def ensure_silence(inbox: Inbox, ws) -> int: + """Mute the frontend-stub's desktop-audio source and assert that NO audio + input remains unmuted. Returns 0 on a fully-silent mixer, 1 otherwise. + + The frontend-stub binds PulsarDesktopAudio to mixer channel 1 at boot + regardless of env -- that is the literal 'sound of the PC'. Mic + process + audio are never created (env popped/unset at spawn). We mute the desktop + source explicitly, then enumerate inputs and verify silence.""" + # 1. Mute the known desktop-audio source. It is created by the stub + # (wasapi_output_capture); if a build omitted it, the mute call simply + # reports the source missing, which is also silence. + r = await request(inbox, ws, "SetInputMute", "mute-desktop", { + "inputName": DESKTOP_AUDIO_SOURCE, + "inputMuted": True, + }) + if req_ok(r): + print(f"-> muted desktop audio source '{DESKTOP_AUDIO_SOURCE}'") + else: + # Not fatal on its own -- the source may be absent on this build. + print(f" note: SetInputMute('{DESKTOP_AUDIO_SOURCE}') declined " + f"({r.get('requestStatus')}); will rely on the input enumeration") + + # 2. Enumerate every input and assert none of the audio ones is unmuted. + # GetInputMute fails for non-audio inputs (no volume interface); we + # treat a successful GetInputMute as 'this is an audio input'. + r = await request(inbox, ws, "GetInputList", "input-list", {}) + if not req_ok(r): + print(f"FAIL: GetInputList failed: {r.get('requestStatus')}") + return 1 + inputs = r["responseData"].get("inputs", []) or [] + audio_inputs: list[str] = [] + unmuted: list[str] = [] + for idx, inp in enumerate(inputs): + name = inp.get("inputName") + if not name: + continue + mr = await request(inbox, ws, f"get-mute-{idx}", "GetInputMute", + {"inputName": name}) + if not req_ok(mr): + # No volume interface -> not an audio input (e.g. browser_source + # with reroute_audio off, scenes). Skip. + continue + audio_inputs.append(name) + if not mr["responseData"].get("inputMuted", False): + unmuted.append(name) + + print(f"-> audio inputs present: {audio_inputs or '(none)'}") + if unmuted: + print(f"FAIL: audio input(s) NOT muted -> PC sound would broadcast: " + f"{unmuted}. Refusing to go live.") + return 1 + print("-> silence confirmed: every audio input is muted (or absent)") + return 0 + + +# -------------------------------------------------------------------------- +# Scene plumbing. +# -------------------------------------------------------------------------- +async def set_capture_browser(inbox: Inbox, ws, rid: str, url: str) -> dict: + """SetCaptureSource(browser_source, url) on the CURRENT program scene. + reroute_audio=False so no page audio reaches the OBS mixer (zero PC sound). + Returns the unwrapped vendor responseData.""" + r = await vendor_call(inbox, ws, rid, "pulsar-scene", "SetCaptureSource", { + "kind": "browser_source", + "url": url, + "width": CANVAS_W, + "height": CANVAS_H, + "fps": 60, + "reroute_audio": False, + }) + return vendor_response_data(r) + + +async def get_current_scene(inbox: Inbox, ws, rid: str) -> Optional[str]: + r = await request(inbox, ws, "GetCurrentProgramScene", rid, {}) + if not req_ok(r): + return None + rd = r["responseData"] + return rd.get("sceneName") or rd.get("currentProgramSceneName") + + +async def set_current_scene(inbox: Inbox, ws, rid: str, name: str) -> bool: + r = await request(inbox, ws, "SetCurrentProgramScene", rid, + {"sceneName": name}) + return req_ok(r) + + +async def try_setup_two_scenes(inbox: Inbox, ws, url_a: str, url_b: str) -> bool: + """Path (a): create a second program scene, give each scene its own + browser_source, leave scene-a current + live. Returns True if the real + two-scene model is established and verified, False to fall back to (b). + + The pulsar-scene plugin always installs the browser_source on whatever + obs_frontend_get_current_scene() returns, so we set scene-b current, paint + it, then set scene-a current and paint it -- that orders the two managed + sources onto two distinct scenes.""" + start_scene = await get_current_scene(inbox, ws, "scene-cur-0") + if not start_scene: + print(" path(a): GetCurrentProgramScene returned nothing; fallback") + return False + print(f" path(a): boot program scene = {start_scene!r}") + + # Create scene-b. ResourceAlreadyExists is fine (idempotent re-run). + r = await request(inbox, ws, "create-scene-b", "CreateScene", + {"sceneName": SCENE_B_NAME}) + if not req_ok(r): + code = r.get("requestStatus", {}).get("code") + # 601 == ResourceAlreadyExists in obs-websocket. Tolerate it. + if code != 601: + print(f" path(a): CreateScene declined ({r.get('requestStatus')}); " + f"fallback") + return False + + # Switch to scene-b and confirm the program scene actually flipped -- this + # is the live test of whether the headless stub honours program-scene + # changes for a CreateScene'd scene at all. + if not await set_current_scene(inbox, ws, "to-b-setup", SCENE_B_NAME): + print(" path(a): SetCurrentProgramScene(scene-b) declined; fallback") + return False + now = await get_current_scene(inbox, ws, "scene-cur-1") + if now != SCENE_B_NAME: + print(f" path(a): program scene did not flip to {SCENE_B_NAME!r} " + f"(got {now!r}); fallback") + return False + + # Paint scene-b crimson. + data_b = await set_capture_browser(inbox, ws, "cap-b", url_b) + if data_b.get("kind") != "browser_source": + print(f" path(a): SetCaptureSource on scene-b failed ({data_b}); " + f"fallback") + return False + + # Switch back to scene-a (the boot scene) and paint it cobalt. + if not await set_current_scene(inbox, ws, "to-a-setup", start_scene): + print(" path(a): could not return to scene-a; fallback") + return False + now = await get_current_scene(inbox, ws, "scene-cur-2") + if now != start_scene: + print(f" path(a): program scene did not return to {start_scene!r}; " + f"fallback") + return False + data_a = await set_capture_browser(inbox, ws, "cap-a", url_a) + if data_a.get("kind") != "browser_source": + print(f" path(a): SetCaptureSource on scene-a failed ({data_a}); " + f"fallback") + return False + + print(f" path(a): two scenes ready -- '{start_scene}' (cobalt) + " + f"'{SCENE_B_NAME}' (crimson); live on '{start_scene}'") + return True + + +# -------------------------------------------------------------------------- +# Broadcast + mid-course switch. +# -------------------------------------------------------------------------- +async def broadcast(inbox: Inbox, ws, stream_key: str, duration_sec: int, + use_real_scene: bool, scene_a: str, url_a: str, url_b: str, + pulsar: "PulsarProcess") -> int: + switch_at = duration_sec / 2.0 + + r = await vendor_call(inbox, ws, "create-dest", "pulsar", + "CreateDestination", { + "name": DESTINATION_NAME, "kind": "twitch", "key": stream_key, + }) + dest_data = vendor_response_data(r) + dest_id = dest_data.get("id") + if not dest_id: + status = vendor_request_status(r) + print(f"FAIL: CreateDestination returned no id; " + f"status={redact(json.dumps(status), stream_key)}") + return 1 + print(f"-> CreateDestination(twitch) id={dest_id}") + + r = await vendor_call(inbox, ws, "start-dest", "pulsar", + "StartDestination", {"id": dest_id}) + if not vendor_response_data(r).get("started"): + status = vendor_request_status(r) + print(f"FAIL: StartDestination not started; " + f"status={redact(json.dumps(status), stream_key)}") + await vendor_call(inbox, ws, "rm-dest", "pulsar", + "RemoveDestination", {"id": dest_id}) + return 1 + print("-> StartDestination started=true -- LIVE on Twitch") + + recording = False + r = await request(inbox, ws, "StartRecord", "start-rec") + if req_ok(r): + recording = True + print(f"-> StartRecord ok (writing under {LIVE_VOD_DIR})") + else: + print(f" warn: StartRecord declined: {r.get('requestStatus')}") + + # The switch must be observable: capture the program scene (path a) or the + # active capture URL (path b) BEFORE the switch so we can assert it changed. + if use_real_scene: + before = await get_current_scene(inbox, ws, "before-switch") + else: + gr = await vendor_call(inbox, ws, "get-cap-before", "pulsar-scene", + "GetCaptureSource", {}) + before = vendor_response_data(gr).get("url") + print(f" pre-switch state = {redact(str(before), stream_key)}") + + rc = 0 + switched = False + start_t = time.time() + poll = 0 + adaptive_seen = 0 + while time.time() - start_t < duration_sec: + await asyncio.sleep(POLL_INTERVAL_SEC) + poll += 1 + elapsed = time.time() - start_t + + # Mid-course SCENE SWITCH at ~duration/2. + if not switched and elapsed >= switch_at: + if use_real_scene: + ok = await set_current_scene(inbox, ws, "do-switch", SCENE_B_NAME) + now = await get_current_scene(inbox, ws, "after-switch") + if not ok or now != SCENE_B_NAME: + print(f"FAIL: scene switch to {SCENE_B_NAME!r} not honoured " + f"(ok={ok} now={now!r})") + rc = 1 + break + print(f"** SCENE SWITCH @ t={elapsed:.1f}s : " + f"program scene {scene_a!r} -> {SCENE_B_NAME!r} (crimson)") + else: + data = await set_capture_browser(inbox, ws, "do-switch", url_b) + gr = await vendor_call(inbox, ws, "get-cap-after", + "pulsar-scene", "GetCaptureSource", {}) + now = vendor_response_data(gr).get("url") + if data.get("kind") != "browser_source" or now != url_b: + print(f"FAIL: fallback URL swap not honoured " + f"(now={redact(str(now), stream_key)})") + rc = 1 + break + print(f"** SCENE SWITCH @ t={elapsed:.1f}s (fallback) : " + f"capture URL scene-a.html -> scene-b.html (crimson)") + switched = True + + r = await vendor_call(inbox, ws, f"get-dest-{poll}", "pulsar", + "GetDestinations", {}) + lst = vendor_response_data(r).get("destinations", []) + ours = next((d for d in lst if d.get("id") == dest_id), None) + if not ours or not ours.get("active"): + print(f"FAIL: destination not active at poll #{poll}: {ours}") + rc = 1 + break + + r = await vendor_call(inbox, ws, f"get-adapt-{poll}", "pulsar", + "GetAdaptiveState", {}) + adapt = vendor_response_data(r) + samples = int(adapt.get("samples", 0)) + adaptive_seen = max(adaptive_seen, samples) + drop_ratio = float(adapt.get("last_drop_ratio", 0.0)) + cur_kbps = adapt.get("current_kbps") + + sr = await request(inbox, ws, "GetStats", f"stats-{poll}") + stats = sr.get("responseData", {}) or {} + fps = stats.get("activeFps") + fps_str = f"{fps:.1f}" if isinstance(fps, (int, float)) else "—" + + print(f" poll #{poll} t={elapsed:.0f}s active=true samples={samples} " + f"drop_ratio={drop_ratio:.4f} bitrate={cur_kbps} fps={fps_str} " + f"switched={switched}") + + if drop_ratio > FRAME_DROP_RATIO_MAX: + print(f"FAIL: frame drop ratio {drop_ratio:.4f} > " + f"{FRAME_DROP_RATIO_MAX} at poll #{poll}") + rc = 1 + break + + if rc == 0 and not switched: + print("FAIL: broadcast ended before the mid-course switch fired") + rc = 1 + + # Stop cleanly (best-effort even on a failed poll). + if recording: + try: + r = await request(inbox, ws, "StopRecord", "stop-rec") + vod = (r.get("responseData", {}) or {}).get("outputPath") + if vod: + print(f"-> StopRecord finalised: {vod}") + print(f"LIVE_VOD_PATH={vod}") + except Exception as exc: # noqa: BLE001 + print(f" warn: StopRecord error: {exc}") + try: + await vendor_call(inbox, ws, "stop-dest", "pulsar", + "StopDestination", {"id": dest_id}) + print("-> StopDestination ok") + except Exception as exc: # noqa: BLE001 + print(f" warn: StopDestination error: {exc}") + try: + await vendor_call(inbox, ws, "rm-dest", "pulsar", + "RemoveDestination", {"id": dest_id}) + except Exception: + pass + + if rc == 0 and adaptive_seen <= 0: + print(f"FAIL: adaptive worker never reported samples (saw {adaptive_seen})") + rc = 1 + if rc == 0: + print(f"-> broadcast clean: switch performed, adaptive_samples={adaptive_seen}") + return rc + + +async def run(url: str, password: str, stream_key: str, duration_sec: int, + http_port: int, force_fallback: bool, pulsar: "PulsarProcess") -> int: + scene_a_url = f"http://127.0.0.1:{http_port}/scene-a.html" + scene_b_url = f"http://127.0.0.1:{http_port}/scene-b.html" + + print(f"connecting: {url}") + async with websockets.connect( + 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": EVENT_SUBSCRIPTION_ALL, + } + if "authentication" in hello["d"]: + a = hello["d"]["authentication"] + identify_d["authentication"] = 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 = Inbox() + + # Guard: browser_source must be registered (full-variant build). + resp = await 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). " + "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)") + + # ZERO PC SOUND: mute desktop audio + assert no unmuted audio input. + if await ensure_silence(inbox, ws) != 0: + return 1 + + # Decide the switch implementation: real scene (a) or URL swap (b). + scene_a = await get_current_scene(inbox, ws, "scene-a-name") or DEFAULT_SCENE_NAME + use_real_scene = False + if force_fallback: + print("-> --force-fallback set: using path (b) URL swap") + else: + print("-> attempting path (a): real two-scene OBS switch") + use_real_scene = await try_setup_two_scenes( + inbox, ws, scene_a_url, scene_b_url) + + if not use_real_scene: + # Path (b): single scene, paint scene-a; the switch later swaps URL. + print("-> path (b): single program scene, browser_source = scene-a.html " + "(switch will swap to scene-b.html at duration/2)") + data = await set_capture_browser(inbox, ws, "cap-fallback", scene_a_url) + if data.get("kind") != "browser_source": + print(f"FAIL: SetCaptureSource(scene-a) failed: {data}") + return 1 + + impl = "REAL SCENE SWITCH (path a)" if use_real_scene else "URL-SWAP FALLBACK (path b)" + print(f"\n[scene-switch] implementation = {impl}") + print(f"[scene-switch] going live to Twitch ({duration_sec}s, " + f"switch @ {duration_sec/2:.0f}s) ...\n") + return await broadcast( + inbox, ws, stream_key, duration_sec, use_real_scene, + scene_a, scene_a_url, scene_b_url, pulsar) + + +def main() -> int: + ap = argparse.ArgumentParser(description="Pulsar Twitch scene-switch probe") + ap.add_argument("--exe", type=pathlib.Path, + default=pathlib.Path(os.environ.get("PULSAR_EXE", str(DEFAULT_EXE))), + help="path to pulsar.exe (default: built rundir)") + ap.add_argument("--duration", type=int, + default=int(os.environ.get("LIVE_TEST_DURATION", "30")), + help="broadcast duration in seconds (default 30); switch at /2") + ap.add_argument("--fps", type=int, + default=int(os.environ.get("LIVE_TEST_FPS", "60")), + help="encoder fps target (default 60)") + ap.add_argument("--force-fallback", action="store_true", + help="skip path (a), use the URL-swap fallback (b)") + ap.add_argument("--ready-timeout", type=float, default=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 + + if not (SCENE_DIR / "scene-a.html").exists() or not (SCENE_DIR / "scene-b.html").exists(): + print(f"error: scene-a.html / scene-b.html missing under {SCENE_DIR}") + return 2 + + if args.duration < 4: + print("error: --duration must be >= 4s so the mid-course switch has room") + return 2 + + stream_key = os.environ.get("TWITCH_STREAM_KEY", "").strip() + if not stream_key: + print("error: TWITCH_STREAM_KEY env var is empty. Set it from the " + "etage-1 secret; never commit. Refusing to broadcast.") + return 2 + + http_port = find_free_port() + httpd = start_scene_server(http_port) + print(f"scene HTTP server: http://127.0.0.1:{http_port}/ " + f"(scene-a.html cobalt / scene-b.html crimson)") + + port = find_free_port() + password = secrets.token_urlsafe(16) + print(f"spawning: {exe}") + print(f" cwd={exe.parent}") + print(f" PULSAR_PORT={port} PULSAR_PASSWORD=") + print(f" TWITCH_STREAM_KEY: ") + + pulsar = 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, stream_key, args.duration, + http_port, args.force_fallback, pulsar, + )) + except KeyboardInterrupt: + print("interrupted") + rc = 130 + except Exception as exc: # noqa: BLE001 — top-level probe diagnostic + print(f"FAIL: {redact(str(exc), stream_key)}") + if pulsar.proc is not None: + print(redact(pulsar.diag(), stream_key)) + 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" {redact(ln, stream_key)}") + print("---- end pulsar stdout ----\n") + pulsar.shutdown() + try: + httpd.shutdown() + httpd.server_close() + except Exception: + pass + 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()) From d634d35f4ec6cc0e8b91a398f026449f02dcb795 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 00:14:24 +0200 Subject: [PATCH 02/12] build: skip ATL-dependent plugins when ATL SDK absent (headless-safe) obs-qsv11, win-dshow, and win-dshow's virtualcam-module compile sources that require the MSVC ATL component (atlbase.h / atlcomcli.h / atlstr.h). ATL is present on the CI windows-2022 runner but absent on a dev box without the "C++ ATL" workload, where it broke the local build. The previous local unblock disabled those three plugins unconditionally, which would have regressed CI build coverage if committed. Replace it with a conditional gate: - patches/0002 now wraps the three plugin registrations in if(PULSAR_HAVE_ATL) ... else (disabled stub). The option defaults ON, so CI -- which never passes the flag -- builds all three plugins exactly as upstream does. No coverage regression, no asymmetry. - scripts/build-win.ps1 probes the toolchain via vswhere for the ATL headers under VC/Tools/MSVC//atlmfc/include and passes -DPULSAR_HAVE_ATL=OFF only when they are missing, logging an explicit warning. The skip is driven by detection, not by a static patch that amputates upstream in all builds. The upstream submodule stays at its pinned SHA; the gate lives entirely in the replayed patch + build script. None of the three plugins are on the headless browser_source live path (x264/nvenc encode + CEF browser source), so the OFF branch has zero functional impact on the Pulsar probe. Verified: local build (ATL absent) -> BUILD_SCRIPT_EXIT=0, pulsar.exe + pulsar-browser.dll staged, qsv11/win-dshow absent from rundir. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ndent-plugins-behind-PULSAR_HAVE_ATL.patch | 92 +++++++++++++++++++ scripts/build-win.ps1 | 77 ++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch diff --git a/patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch b/patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch new file mode 100644 index 0000000..737d739 --- /dev/null +++ b/patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch @@ -0,0 +1,92 @@ +From baec3cbb38db3aeb74741f90cb431e49e8bbe765 Mon Sep 17 00:00:00 2001 +From: Keeper +Date: Mon, 8 Jun 2026 00:05:20 +0200 +Subject: build: gate ATL-dependent plugins behind PULSAR_HAVE_ATL + +obs-qsv11, win-dshow, and win-dshow's virtualcam-module compile sources +that require the MSVC ATL component (atlbase.h / atlcomcli.h / atlstr.h). +That component is present on the CI windows-2022 runner but absent on +toolchains where the ATL workload was not installed. + +Replace the previous unconditional skip with a CMake option, +PULSAR_HAVE_ATL, that defaults ON. With the default (CI), all three +plugins build exactly as upstream does -- no coverage regression. The +Pulsar build script (scripts/build-win.ps1) probes the toolchain for the +ATL headers and passes -DPULSAR_HAVE_ATL=OFF only when they are missing, +in which case the three plugins register as disabled stubs +(OBS_MODULES_DISABLED) instead of failing configure. + +None of these three plugins are on the headless browser_source live path +(x264/nvenc encode + CEF browser source), so the OFF branch has zero +functional impact on the Pulsar headless probe. + +Pulsar-Patch: 0002 +Upstream-Candidate: no (toolchain-conditional build gate) +--- + plugins/CMakeLists.txt | 38 ++++++++++++++++++++++++++++++++------ + 1 file changed, 32 insertions(+), 6 deletions(-) + +diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt +index c12f015c8..24afeac44 100644 +--- a/plugins/CMakeLists.txt ++++ b/plugins/CMakeLists.txt +@@ -2,6 +2,18 @@ cmake_minimum_required(VERSION 3.28...3.30) + + option(ENABLE_PLUGINS "Enable building OBS plugins" ON) + ++# PULSAR: PULSAR_HAVE_ATL gates the three plugins that compile sources ++# requiring the MSVC ATL component (atlbase.h / atlcomcli.h / atlstr.h): ++# obs-qsv11, win-dshow and its virtualcam-module. Default ON so any build ++# with ATL present (the CI windows runner) compiles them exactly as ++# upstream does. scripts/build-win.ps1 probes the toolchain for the ATL ++# headers and passes -DPULSAR_HAVE_ATL=OFF only when they are absent ++# (local dev toolchain without the ATL component). None of these three ++# plugins are on the headless browser_source live path (x264/nvenc encode ++# + CEF browser source), so disabling them has zero functional impact ++# there. ++option(PULSAR_HAVE_ATL "Build ATL-dependent plugins (qsv11, win-dshow, virtualcam)" ON) ++ + if(NOT ENABLE_PLUGINS) + set_property(GLOBAL APPEND PROPERTY OBS_FEATURES_DISABLED "Plugin Support") + return() +@@ -63,11 +75,18 @@ add_obs_plugin(obs-filters) + add_obs_plugin(obs-libfdk) + add_obs_plugin(obs-nvenc PLATFORMS WINDOWS LINUX ARCHITECTURES x64 x86_64) + add_obs_plugin(obs-outputs) +-add_obs_plugin( +- obs-qsv11 +- PLATFORMS WINDOWS LINUX +- ARCHITECTURES x64 x86_64 +-) ++if(PULSAR_HAVE_ATL) ++ add_obs_plugin( ++ obs-qsv11 ++ PLATFORMS WINDOWS LINUX ++ ARCHITECTURES x64 x86_64 ++ ) ++else() ++ # PULSAR: ATL absent -- obs-qsv11 needs atlbase.h. Register a disabled ++ # stub so OBS_MODULES_DISABLED reports it instead of failing configure. ++ add_custom_target(obs-qsv11) ++ target_disable(obs-qsv11) ++endif() + add_obs_plugin(obs-text PLATFORMS WINDOWS) + add_obs_plugin(obs-transitions) + add_obs_plugin( +@@ -86,5 +105,12 @@ add_obs_plugin(sndio PLATFORMS LINUX FREEBSD OPENBSD) + add_obs_plugin(text-freetype2) + add_obs_plugin(vlc-video WITH_MESSAGE) + add_obs_plugin(win-capture PLATFORMS WINDOWS) +-add_obs_plugin(win-dshow PLATFORMS WINDOWS) ++if(PULSAR_HAVE_ATL) ++ add_obs_plugin(win-dshow PLATFORMS WINDOWS) ++else() ++ # PULSAR: ATL absent -- win-dshow pulls ATL via the Elgato ++ # capture-device-support sources and its virtualcam-module subdir. ++ add_custom_target(win-dshow) ++ target_disable(win-dshow) ++endif() + add_obs_plugin(win-wasapi PLATFORMS WINDOWS) +-- +2.54.0.windows.1 + diff --git a/scripts/build-win.ps1 b/scripts/build-win.ps1 index dbe67db..6267dab 100644 --- a/scripts/build-win.ps1 +++ b/scripts/build-win.ps1 @@ -98,6 +98,74 @@ Write-Host "Using cmake: $cmake" Write-Host "Preset: $preset" Write-Host "Source: $upstream" +# --- ATL detection --------------------------------------------------------- +# +# Three upstream plugins -- obs-qsv11, win-dshow and its virtualcam-module +# -- compile sources that require the MSVC ATL component (atlbase.h / +# atlcomcli.h / atlstr.h). ATL ships as a separate Visual Studio +# workload/component under the MSVC toolset (VC/Tools/MSVC//atlmfc/ +# include/), NOT with the Windows SDK. The CI windows-2022 runner has it; +# a dev box without the "C++ ATL" component does not. +# +# We probe every installed VS instance via vswhere, then check each MSVC +# toolset's atlmfc/include for the three headers. If found -> full build +# (PULSAR_HAVE_ATL=ON, identical to upstream / CI). If not -> we pass +# -DPULSAR_HAVE_ATL=OFF so patch 0002 registers those three plugins as +# disabled stubs instead of failing configure on the missing headers. +# +# None of the three are on the headless browser_source live path +# (x264/nvenc encode + CEF browser source), so the OFF branch has zero +# functional impact on the Pulsar probe. +function Test-AtlAvailable { + $atlHeaders = @('atlbase.h', 'atlcomcli.h', 'atlstr.h') + + # Candidate VS installation roots. Prefer vswhere (authoritative); + # fall back to common fixed paths if vswhere is unavailable. + $vsRoots = @() + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $vswhere) { + $found = & $vswhere -products '*' -property installationPath 2>$null + if ($LASTEXITCODE -eq 0 -and $found) { + $vsRoots += @($found | Where-Object { $_ -and (Test-Path $_) }) + } + } + if ($vsRoots.Count -eq 0) { + foreach ($p in @( + 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise', + 'C:\Program Files\Microsoft Visual Studio\2022\Professional', + 'C:\Program Files\Microsoft Visual Studio\2022\Community', + 'C:\Program Files\Microsoft Visual Studio\2022\BuildTools', + 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools' + )) { + if (Test-Path $p) { $vsRoots += $p } + } + } + + foreach ($vsRoot in $vsRoots) { + $msvcRoot = Join-Path $vsRoot 'VC\Tools\MSVC' + if (-not (Test-Path $msvcRoot)) { continue } + $toolsets = Get-ChildItem $msvcRoot -Directory -ErrorAction SilentlyContinue + foreach ($ts in $toolsets) { + $atlInclude = Join-Path $ts.FullName 'atlmfc\include' + if (-not (Test-Path $atlInclude)) { continue } + $allPresent = $true + foreach ($h in $atlHeaders) { + if (-not (Test-Path (Join-Path $atlInclude $h))) { $allPresent = $false; break } + } + if ($allPresent) { + Write-Host "ATL headers found: $atlInclude" + return $true + } + } + } + return $false +} + +$haveAtl = Test-AtlAvailable +if (-not $haveAtl) { + Write-Warning "ATL not found -> skipping qsv11/virtualcam/win-dshow ; headless browser_source path unaffected" +} + # --- Apply Pulsar patches onto upstream/ ----------------------------------- # # Reset upstream/ to the SHA recorded by Pulsar's submodule pointer, then @@ -186,6 +254,15 @@ if ($Stage -in @('configure', 'all')) { # ENABLE_BROWSER - default OFF in obs-browser, but the windows-x64 # preset forces it ON in cacheVariables. Override. $extraArgs = @() + # ATL gate (see Test-AtlAvailable). Default ON in the patched + # CMakeLists, so we only ever need to force OFF; passing ON + # explicitly when ATL is present keeps the cache value unambiguous + # across re-configures. + if ($haveAtl) { + $extraArgs += '-DPULSAR_HAVE_ATL=ON' + } else { + $extraArgs += '-DPULSAR_HAVE_ATL=OFF' + } if (-not $GuiBuild) { $extraArgs += '-DENABLE_FRONTEND=OFF' $extraArgs += '-DENABLE_UI=OFF' From 7dc3def0d7a8723bd51420daa5e7267c8b772ee6 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 00:18:12 +0200 Subject: [PATCH 03/12] docs: add ATL build-gate runbook and toolchain note Documents the C1083 / missing ATL headers failure mode introduced by the PULSAR_HAVE_ATL gate (commit d634d35, PR #43): symptom, diagnostic, root cause, gate mechanics (Test-AtlAvailable + patch 0002), verification, rollback, and optional ATL install for full local parity. Adds a pointer in DEVELOPMENT.md Toolchain table and Troubleshooting section so the next developer hitting the error is one click from the runbook. Refs #43 Co-Authored-By: Claude Sonnet 4.6 --- docs/DEVELOPMENT.md | 11 +- docs/runbooks/atl-missing-build-failure.md | 157 +++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 docs/runbooks/atl-missing-build-failure.md diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8ad8cc5..6962e06 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -9,7 +9,7 @@ hand for debugging. | | | |---|---| | OS | Windows 10/11 x64 | -| Compiler | Visual Studio 2022 Build Tools — workload "Desktop development with C++" + Windows 11 SDK | +| Compiler | Visual Studio 2022 Build Tools — workload "Desktop development with C++" + Windows 11 SDK. The optional "C++ ATL" component (`Microsoft.VisualStudio.Component.VC.ATL`) is **not** required for the headless path but enables `obs-qsv11` / `win-dshow` locally (see [ATL runbook](runbooks/atl-missing-build-failure.md)). | | CMake | 3.28+ | | Generator | Visual Studio 17 2022 (default) or Ninja | | Yarn | 4.x via Corepack (used by upstream's build scripts) | @@ -192,6 +192,15 @@ matching binary. Bump it, commit, tag. ## Troubleshooting +### `error C1083: Cannot open include file: 'atlbase.h'` (or `atlcomcli.h` / `atlstr.h`) + +ATL headers missing — the "C++ ATL" VS component is not installed on this machine. +`scripts/build-win.ps1` detects this automatically and skips the three affected +plugins (`obs-qsv11`, `win-dshow`, `virtualcam-module`) with `PULSAR_HAVE_ATL=OFF`. +If you are invoking CMake directly (bypassing the script) the build will fail. +Full diagnosis, gate mechanics, rollback, and optional ATL install instructions: +[docs/runbooks/atl-missing-build-failure.md](runbooks/atl-missing-build-failure.md). + ### `pulsar.exe did not signal ready within 30000ms` The most common cause is `cwd` being wrong — libobs cannot find diff --git a/docs/runbooks/atl-missing-build-failure.md b/docs/runbooks/atl-missing-build-failure.md new file mode 100644 index 0000000..d306481 --- /dev/null +++ b/docs/runbooks/atl-missing-build-failure.md @@ -0,0 +1,157 @@ +# Runbook — Build failure: ATL headers missing (C1083) + +**Applies to:** local Windows build without the VS2022 "C++ ATL" workload. +**Fixed by:** commit `d634d35` (PR #43, `patches/0002`, `scripts/build-win.ps1`). + +--- + +## Symptom + +`cmake --build` fails on one or more of: + +``` +error C1083: Cannot open include file: 'atlbase.h': No such file or directory +error C1083: Cannot open include file: 'atlcomcli.h': No such file or directory +error C1083: Cannot open include file: 'atlstr.h': No such file or directory +``` + +Faulting plugins: `obs-qsv11`, `win-dshow`, `virtualcam-module` (a sub-target of `win-dshow`). +The error appears during the CMake build step, not during configure. + +--- + +## Diagnostic + +ATL headers live under the MSVC toolset, not the Windows SDK: + +``` +\VC\Tools\MSVC\\atlmfc\include\ +``` + +Check whether the directory exists and contains the three headers: + +```powershell +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -products '*' -property installationPath | Select-Object -First 1 +$msvcRoot = Join-Path $vs "VC\Tools\MSVC" +Get-ChildItem $msvcRoot | ForEach-Object { + $atl = Join-Path $_.FullName "atlmfc\include\atlbase.h" + [PSCustomObject]@{ Toolset = $_.Name; AtlPresent = (Test-Path $atl) } +} +``` + +If every row shows `AtlPresent = False`, ATL is not installed. This is the root cause. + +`scripts/build-win.ps1` runs `Test-AtlAvailable` automatically and emits: + +``` +WARNING: ATL not found -> skipping qsv11/virtualcam/win-dshow ; headless browser_source path unaffected +``` + +If you see that warning and the build succeeded anyway, the gate fired correctly — you are in the OFF branch (see below). If you see the warning and the build *still* fails with C1083, the gate was not applied — confirm you are running `scripts/build-win.ps1` and not invoking CMake directly. + +--- + +## Root cause + +The MSVC "C++ ATL" component (`Microsoft.VisualStudio.Component.VC.ATL`) is **not** part of the default "Desktop development with C++" workload install. It is an optional component. The CI runner (`windows-2022`) has it; a freshly installed VS2022 Build Tools box typically does not. + +`obs-qsv11`, `win-dshow`, and `win-dshow`'s `virtualcam-module` include ATL headers unconditionally in upstream. Installing ATL requires an elevated UAC prompt (VS installer) that cannot be satisfied in a non-interactive build context. + +--- + +## Fix — the `PULSAR_HAVE_ATL` gate (commit `d634d35`) + +The fix is already in place. Two artifacts implement it: + +**`patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch`** +Wraps the three plugin registrations in `plugins/CMakeLists.txt` behind +`if(PULSAR_HAVE_ATL) ... else()`. The `else()` branch registers each plugin +as a disabled stub via `target_disable()` so CMake reports them in +`OBS_MODULES_DISABLED` instead of failing configure. The CMake option +defaults to `ON`. + +**`scripts/build-win.ps1` — `Test-AtlAvailable` function** +Runs before the configure step. Uses `vswhere` to enumerate installed VS +instances, then checks each MSVC toolset's `atlmfc\include\` for all three +headers. Falls back to a fixed list of common VS install paths if `vswhere` +is unavailable. Outcome: + +| Detection result | Flag injected | Effect | +|---|---|---| +| ATL headers found | `-DPULSAR_HAVE_ATL=ON` (or none — same as default) | All three plugins build normally | +| ATL headers absent | `-DPULSAR_HAVE_ATL=OFF` | Three plugins registered as disabled stubs; build continues | + +Because the option defaults `ON`, CI builds (`windows-2022`) never receive the +flag and build everything exactly as upstream — no coverage regression. + +--- + +## Verification (after the gate fires) + +After a local build with ATL absent: + +1. `scripts/build-win.ps1` exits 0. +2. `upstream/build_x64/rundir/RelWithDebInfo/obs-plugins/64bit/` contains + `pulsar-browser.dll` and the encoder/capture plugins but **not** + `obs-qsv11.dll`, `win-dshow.dll`, or `win-dshow-virtualcam.dll`. +3. `pulsar.exe` starts and prints the `PULSAR_READY` sentinel. +4. The offline probe suite passes — `obs-qsv11`, `win-dshow`, and + `virtualcam-module` are not exercised by any probe (none are on the + headless `browser_source` → x264/nvenc → CEF path). + +--- + +## Rollback + +To revert to unconditional build of all plugins (equivalent to upstream +before this fix), pass the flag explicitly: + +```powershell +.\scripts\build-win.ps1 -CMakeArgs @("-DPULSAR_HAVE_ATL=ON") +``` + +This forces the ON branch regardless of detection. If ATL is genuinely +absent the build will fail with C1083 — which is the correct signal that +the toolchain is incomplete for a full build. + +Alternatively, remove the `-DPULSAR_HAVE_ATL=OFF` injection from +`Test-AtlAvailable` in `scripts/build-win.ps1` to restore the old +unconditional behavior (not recommended — reintroduces the original breakage). + +--- + +## Restoring local ↔ CI parity (optional) + +To build all three plugins locally — matching CI exactly — install the ATL component: + +**Via VS Installer (GUI):** +Open "Visual Studio Installer" → Modify your VS2022 Build Tools installation → +Individual components → search "ATL" → check +"C++ ATL for latest v143 build tools (x86 & x64)" → Modify. + +**Via winget / `vs_buildtools.exe` (elevated PowerShell):** + +```powershell +winget install Microsoft.VisualStudio.2022.BuildTools --override "--add Microsoft.VisualStudio.Component.VC.ATL --quiet --wait" +``` + +After install, `Test-AtlAvailable` will return `$true` on the next build and the three plugins will compile. No code change needed. + +--- + +## Local ↔ CI asymmetry note + +| | Local (ATL absent) | CI (`windows-2022`) | +|---|---|---| +| `obs-qsv11` | Disabled stub | Built | +| `win-dshow` | Disabled stub | Built | +| `virtualcam-module` | Disabled stub | Built | +| `pulsar.exe` | Built, fully functional | Built, fully functional | +| Probe suite | Passes (those plugins untested) | Passes | +| Headless live path | Unaffected | Unaffected | + +The asymmetry is intentional and safe for Pulsar's use case. The three +skipped plugins are capture/encode peripherals not required by the headless +`browser_source` path. If a future feature requires QSV hardware encode or +DirectShow capture in local development, install ATL (see above). From 2c3295cd9672345d6e1c9f1e79c4c6c82f43ada2 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 00:47:03 +0200 Subject: [PATCH 04/12] ci: add secret-scan/deps-audit/lockfile/codeowners gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pulsar CI proved the build (pipeline.yml) but carried none of the étage-0 merge-gate conformance checks (docs/rules/git.md §1, docs/rules/security.md §Détection). Add a dedicated compliance.yml so a PR cannot be merged while leaking a secret, shipping a high/critical dependency CVE, drifting its lockfile, or breaking CODEOWNERS. - secret-scan: trufflehog over git history + working tree (verified-only) plus detect-secrets against a new .secrets.baseline. - deps-audit: npm audit --omit=dev --audit-level=high (deps scope = npm; the Python probe glue is dev-only, no lock to pip-audit). - lockfile-check: npm ci --dry-run + post-resolution clean-tree assert, and a guard rejecting a stray yarn.lock. - codeowners-check: structural validation of .github/CODEOWNERS. Separate workflow from pipeline.yml on purpose: governance vs build, all-ubuntu/cheap, independent red surface, one check per gate in gh pr checks. No error-suppression toggle anywhere -- each job blocks. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/compliance.yml | 245 +++++++++++++++++++++++++++++++ .secrets.baseline | 48 ++++++ 2 files changed, 293 insertions(+) create mode 100644 .github/workflows/compliance.yml create mode 100644 .secrets.baseline diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml new file mode 100644 index 0000000..1c90733 --- /dev/null +++ b/.github/workflows/compliance.yml @@ -0,0 +1,245 @@ +# Pulsar compliance gates -- étage-0 merge-gate conformance. +# +# These are the conformance checks required by the org-wide merge gate +# (docs/rules/git.md §1 + docs/rules/security.md §Détection). The build +# pipeline (pipeline.yml) proves pulsar.exe builds and broadcasts; this +# workflow proves the change is mergeable per policy: no leaked secret, +# no high/critical dependency CVE, a lockfile that is in sync, and a +# valid CODEOWNERS. +# +# Kept in a SEPARATE workflow from pipeline.yml on purpose: +# - different concern (governance vs build), different runners (all +# ubuntu, cheap + fast -- no MSVC, no Windows), +# - independent failure surface : a red secret-scan must not be tangled +# with the C++ build graph, and vice versa. Each job is its own check +# in `gh pr checks` so the reviewer sees exactly which gate broke. +# +# HARD RULE : no error-suppression anywhere (the step-skip toggle is +# banned per docs/rules/git.md). Every job here is allowed -- and intended +# -- to turn the PR red and block the merge. +# +# Trigger parity with pipeline.yml : pull_request to main fires once per +# push (the dedup reason pipeline.yml documents), plus push to main and +# tags so post-merge / release refs are re-checked. paths-ignore mirrors +# pipeline.yml: a docs-only / changelog / licence change can't introduce +# a secret-in-code, a dep CVE, a lockfile drift or a CODEOWNERS break. + +name: compliance + +on: + push: + branches: [main] + tags: + - 'v*.*.*' + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'CHANGELOG.md' + - '.gitignore' + - 'LICENSE' + pull_request: + branches: [main] + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'CHANGELOG.md' + - '.gitignore' + - 'LICENSE' + +concurrency: + group: compliance-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + + # ── secret-scan : trufflehog (filesystem + git history) + detect-secrets ── + # Two complementary scanners : + # - trufflehog scans the full git history of the diff range (PR : base + # ..head ; push : the pushed range) AND the working tree, with + # --only-verified so a finding is a credential that actually + # authenticates somewhere -- not an entropy false-positive. + # - detect-secrets audits the working tree against .secrets.baseline. + # Any NEW finding not already in the baseline fails the job; this is + # the entropy/keyword net trufflehog's verified-only mode skips. + # Either scanner flagging a real/new secret turns the job red. A leaked + # secret => rotate + purge (security.md), never just revert. + secret-scan: + name: secret-scan + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: TruffleHog (history + filesystem, verified secrets) + uses: trufflesecurity/trufflehog@main + with: + # PR : scan base..head. push : the action infers the pushed + # range from the event. Defaulting both to the repo path also + # scans the checked-out working tree. + path: ./ + base: ${{ github.event.pull_request.base.sha || github.event.before }} + head: ${{ github.event.pull_request.head.sha || github.sha }} + extra_args: --only-verified + + - name: Setup Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install detect-secrets + run: python -m pip install --upgrade "detect-secrets==1.5.0" + + - name: detect-secrets audit against baseline + # scan the tree, then diff against the committed baseline. If the + # scan surfaces any finding absent from .secrets.baseline, the + # baseline is out of date *because a new secret-shaped string + # appeared* -> fail. New legitimate allowlist entries must be added + # to the baseline in the same PR (detect-secrets scan > .secrets.baseline). + run: | + set -euo pipefail + detect-secrets scan --baseline .secrets.baseline + # `scan --baseline` updates the file in place; a non-empty diff + # means new findings were detected that were not in the baseline. + if ! git diff --quiet -- .secrets.baseline; then + echo "::error::detect-secrets found secret-shaped strings not present in .secrets.baseline." + echo "Review them. If legitimate, run 'detect-secrets scan > .secrets.baseline' and audit; if a real secret, ROTATE + purge per security.md." + git --no-pager diff -- .secrets.baseline + exit 1 + fi + echo "detect-secrets: no new findings beyond the baseline." + + # ── deps-audit : npm high/critical CVE gate ────────────────────────── + # Dependency scope = npm. The repo's runtime deps are the @clodocapeo/ + # pulsar-* JS packages (package.json + package-lock.json). The Python + # side is dev-only probe glue (`websockets`), not a shipped/pinned + # dependency surface, so there is no requirements lock to pip-audit -- + # the deps gate that matters for what ships is npm. --omit=dev so the + # gate reflects what consumers actually install (dev-only advisories, + # e.g. the vitest test toolchain, do not block a merge). --audit-level + # =high : moderate/low advise but do not block; high+critical block. + deps-audit: + name: deps-audit + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: npm audit (production deps, high+critical block) + run: npm audit --omit=dev --audit-level=high + + # ── lockfile-check : package-lock.json is in sync + committed ──────── + # `npm ci` refuses to run if package.json and package-lock.json are out + # of sync, so `npm ci --dry-run` is the canonical "lockfile drift" + # detector -- it errors on any divergence or unpinned dependency without + # touching node_modules. We then assert the working tree is still clean + # so a lockfile that gets rewritten on install (e.g. a non-deterministic + # or stale lock) is caught as an uncommitted change. + lockfile-check: + name: lockfile-check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Assert no stray yarn.lock (npm is the lock of record) + run: | + set -euo pipefail + if [ -f yarn.lock ]; then + echo "::error::yarn.lock present but Pulsar uses npm (package-lock.json) as the lock of record. Remove yarn.lock or migrate intentionally." + exit 1 + fi + echo "no yarn.lock -- npm is the sole lockfile." + + - name: npm ci --dry-run (lockfile in sync, deps pinned) + env: + PULSAR_BUNDLE_SKIP_POSTINSTALL: '1' + run: npm ci --dry-run + + - name: Assert lockfile unchanged after resolution + run: | + set -euo pipefail + if ! git diff --quiet -- package-lock.json; then + echo "::error::package-lock.json drifted during resolution -- regenerate with 'npm install' and commit." + git --no-pager diff -- package-lock.json + exit 1 + fi + echo "package-lock.json is in sync and committed." + + # ── codeowners-check : CODEOWNERS exists and is syntactically valid ── + # A merge gate that relies on CODEOWNERS for review routing is only as + # good as the file being present + parseable. We assert it exists, then + # lint it with the same parser semantics GitHub uses (pattern + at least + # one @owner / team / email per non-comment line). No external network + # call -- the validation is purely structural. + codeowners-check: + name: codeowners-check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Locate + validate CODEOWNERS + run: | + set -euo pipefail + file="" + for cand in CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS; do + if [ -f "$cand" ]; then file="$cand"; break; fi + done + if [ -z "$file" ]; then + echo "::error::No CODEOWNERS found (looked in /, /.github, /docs)." + exit 1 + fi + echo "Validating $file" + # disable pathname expansion : CODEOWNERS patterns like '*' or + # '/plugins/*' must be treated literally, not glob-expanded + # against the runner's working tree by the unquoted `set --`. + set -f + fail=0 + lineno=0 + while IFS= read -r line || [ -n "$line" ]; do + lineno=$((lineno+1)) + # strip leading/trailing whitespace + trimmed="$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + # skip blank + comment lines + [ -z "$trimmed" ] && continue + case "$trimmed" in \#*) continue;; esac + # a rule line is: [...] + # every token after the first must be @user, @org/team, or an email + set -- $trimmed + pattern="$1"; shift + if [ "$#" -eq 0 ]; then + echo "::error::$file:$lineno: rule '$pattern' has no owner." + fail=1; continue + fi + for owner in "$@"; do + case "$owner" in + @*/*) : ;; # @org/team + @*) : ;; # @user + *@*.*) : ;; # email + *) + echo "::error::$file:$lineno: invalid owner token '$owner' (expected @user, @org/team, or email)." + fail=1 ;; + esac + done + done < "$file" + [ "$fail" -eq 0 ] || exit 1 + echo "CODEOWNERS is syntactically valid." diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..2f04911 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,48 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { "name": "ArtifactoryDetector" }, + { "name": "AWSKeyDetector" }, + { "name": "AzureStorageKeyDetector" }, + { "name": "Base64HighEntropyString", "limit": 4.5 }, + { "name": "BasicAuthDetector" }, + { "name": "CloudantDetector" }, + { "name": "DiscordBotTokenDetector" }, + { "name": "GitHubTokenDetector" }, + { "name": "GitLabTokenDetector" }, + { "name": "HexHighEntropyString", "limit": 3.0 }, + { "name": "IbmCloudIamDetector" }, + { "name": "IbmCosHmacDetector" }, + { "name": "IPPublicDetector" }, + { "name": "JwtTokenDetector" }, + { "name": "KeywordDetector", "keyword_exclude": "" }, + { "name": "MailchimpDetector" }, + { "name": "NpmDetector" }, + { "name": "OpenAIDetector" }, + { "name": "PrivateKeyDetector" }, + { "name": "PypiTokenDetector" }, + { "name": "SendGridDetector" }, + { "name": "SlackDetector" }, + { "name": "SoftlayerDetector" }, + { "name": "SquareOAuthDetector" }, + { "name": "StripeDetector" }, + { "name": "TelegramBotTokenDetector" }, + { "name": "TwilioKeyDetector" } + ], + "filters_used": [ + { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, + { "path": "detect_secrets.filters.common.is_baseline_file" }, + { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 }, + { "path": "detect_secrets.filters.heuristic.is_indirect_reference" }, + { "path": "detect_secrets.filters.heuristic.is_likely_id_string" }, + { "path": "detect_secrets.filters.heuristic.is_lock_file" }, + { "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" }, + { "path": "detect_secrets.filters.heuristic.is_potential_uuid" }, + { "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" }, + { "path": "detect_secrets.filters.heuristic.is_sequential_string" }, + { "path": "detect_secrets.filters.heuristic.is_swagger_file" }, + { "path": "detect_secrets.filters.heuristic.is_templated_secret" } + ], + "results": {}, + "generated_at": "2026-06-08T00:00:00Z" +} From 5eecc1665500cb0519ff83e3d89c91707dcbd044 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 00:47:11 +0200 Subject: [PATCH 05/12] build: fail (not warn) on missing ATL in CI A missing-ATL detection result on a CI runner is not the benign "dev box without the C++ ATL component" case the warning was written for: the windows-2022 runner ships ATL, so a negative there is a detection false-negative or a broken runner image. The old Write-Warning let the build silently drop to PULSAR_HAVE_ATL=OFF, amputating qsv11/virtualcam/ win-dshow from the binary while CI stayed green -- a thinner artefact shipped under a green check. Throw instead when GITHUB_ACTIONS/CI is set, turning the build red so the gap is visible rather than masked. Local builds (no CI env) keep the warning + skip -- the intended dev convenience documented in the ATL runbook. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/build-win.ps1 | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/build-win.ps1 b/scripts/build-win.ps1 index 6267dab..dfff313 100644 --- a/scripts/build-win.ps1 +++ b/scripts/build-win.ps1 @@ -163,6 +163,17 @@ function Test-AtlAvailable { $haveAtl = Test-AtlAvailable if (-not $haveAtl) { + # CI must build the full plugin set (windows-2022 ships the C++ ATL + # component), so a missing-ATL result on a CI runner is NOT a benign + # "dev box without ATL" -- it is a detection false-negative or a + # broken runner image. Silently dropping to the OFF branch there would + # amputate qsv11/virtualcam/win-dshow from the build and ship a thinner + # binary while CI stays green. Fail loud instead: turn the CI red so the + # gap is visible rather than masked. Locally (no GITHUB_ACTIONS / CI), + # keep the warning + skip -- that path is the intended dev convenience. + if ($env:GITHUB_ACTIONS -or $env:CI) { + throw "ATL not found in a CI context (GITHUB_ACTIONS/CI set). CI runners must have the MSVC 'C++ ATL' component so qsv11/virtualcam/win-dshow build. This is a detection false-negative or a broken runner image, not an expected skip -- failing the build instead of silently dropping plugins. See docs/runbooks/atl-missing-build-failure.md." + } Write-Warning "ATL not found -> skipping qsv11/virtualcam/win-dshow ; headless browser_source path unaffected" } From d257bafd4fa06600e44b378a0007bf56fbde2ceb 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 00:50:06 +0200 Subject: [PATCH 06/12] ci: fix compliance gates on first CI run Two real environment-specific failures surfaced by the gates' own first run on the ubuntu runners (not reproducible on the win dev box): - lockfile-check: @clodocapeo/pulsar-bundle pins os:win32/cpu:x64, so `npm ci --dry-run` aborts with EBADPLATFORM on ubuntu before checking lockfile sync. Add --force (same bypass npm-publish already uses); a genuine drift still fails distinctly (EUSAGE), so the sync guarantee is intact. - secret-scan: detect-secrets flagged 12 heuristic matches absent from the empty baseline. All reviewed and confirmed NON-secrets: UI locale labels ("Server Password"), doc/test placeholder passwords ("dev-only-do-not-ship-this", fake-${Date.now()}), the password= token in this workflow's own grep, and a viewer-scoped localhost JWT test fixture in probe-m6-live.py. Record them in .secrets.baseline as the audited allowlist; any NEW finding beyond these still turns the gate red. TruffleHog --only-verified found zero live credentials. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/compliance.yml | 9 +- .secrets.baseline | 260 ++++++++++++++++++++++++++----- 2 files changed, 228 insertions(+), 41 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 1c90733..738e827 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -169,9 +169,16 @@ jobs: echo "no yarn.lock -- npm is the sole lockfile." - name: npm ci --dry-run (lockfile in sync, deps pinned) + # --force : @clodocapeo/pulsar-bundle declares os:["win32"] + # cpu:["x64"], so on the ubuntu runner npm aborts with + # EBADPLATFORM before it ever checks lockfile sync. --force + # bypasses the os/cpu gate (same reason npm-publish in + # pipeline.yml uses it) WITHOUT weakening the sync check: a real + # lockfile drift surfaces as a distinct EUSAGE/"can only install + # with an up to date package-lock" error, not EBADPLATFORM. env: PULSAR_BUNDLE_SKIP_POSTINSTALL: '1' - run: npm ci --dry-run + run: npm ci --dry-run --force - name: Assert lockfile unchanged after resolution run: | diff --git a/.secrets.baseline b/.secrets.baseline index 2f04911..f95d64c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,48 +1,228 @@ { "version": "1.5.0", "plugins_used": [ - { "name": "ArtifactoryDetector" }, - { "name": "AWSKeyDetector" }, - { "name": "AzureStorageKeyDetector" }, - { "name": "Base64HighEntropyString", "limit": 4.5 }, - { "name": "BasicAuthDetector" }, - { "name": "CloudantDetector" }, - { "name": "DiscordBotTokenDetector" }, - { "name": "GitHubTokenDetector" }, - { "name": "GitLabTokenDetector" }, - { "name": "HexHighEntropyString", "limit": 3.0 }, - { "name": "IbmCloudIamDetector" }, - { "name": "IbmCosHmacDetector" }, - { "name": "IPPublicDetector" }, - { "name": "JwtTokenDetector" }, - { "name": "KeywordDetector", "keyword_exclude": "" }, - { "name": "MailchimpDetector" }, - { "name": "NpmDetector" }, - { "name": "OpenAIDetector" }, - { "name": "PrivateKeyDetector" }, - { "name": "PypiTokenDetector" }, - { "name": "SendGridDetector" }, - { "name": "SlackDetector" }, - { "name": "SoftlayerDetector" }, - { "name": "SquareOAuthDetector" }, - { "name": "StripeDetector" }, - { "name": "TelegramBotTokenDetector" }, - { "name": "TwilioKeyDetector" } + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } ], "filters_used": [ - { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, - { "path": "detect_secrets.filters.common.is_baseline_file" }, - { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 }, - { "path": "detect_secrets.filters.heuristic.is_indirect_reference" }, - { "path": "detect_secrets.filters.heuristic.is_likely_id_string" }, - { "path": "detect_secrets.filters.heuristic.is_lock_file" }, - { "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" }, - { "path": "detect_secrets.filters.heuristic.is_potential_uuid" }, - { "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" }, - { "path": "detect_secrets.filters.heuristic.is_sequential_string" }, - { "path": "detect_secrets.filters.heuristic.is_swagger_file" }, - { "path": "detect_secrets.filters.heuristic.is_templated_secret" } + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } ], - "results": {}, + "results": { + ".github/workflows/compliance.yml": [ + { + "type": "Secret Keyword", + "filename": ".github/workflows/compliance.yml", + "hashed_secret": "2b81714642b5c9c647d08da3c73b5fad9ed4b260", + "is_verified": false, + "line_number": 104 + } + ], + "docs/DEVELOPMENT.md": [ + { + "type": "Secret Keyword", + "filename": "docs/DEVELOPMENT.md", + "hashed_secret": "8cb202358b433b002379b0a492443bec0adcee99", + "is_verified": false, + "line_number": 57 + } + ], + "packages/pulsar-bundle-full/tests/fake-pulsar.mjs": [ + { + "type": "Secret Keyword", + "filename": "packages/pulsar-bundle-full/tests/fake-pulsar.mjs", + "hashed_secret": "973229c027606bc8cf2cbfb6259f856f35e6196d", + "is_verified": false, + "line_number": 26 + } + ], + "packages/pulsar-bundle/tests/fake-pulsar.mjs": [ + { + "type": "Secret Keyword", + "filename": "packages/pulsar-bundle/tests/fake-pulsar.mjs", + "hashed_secret": "973229c027606bc8cf2cbfb6259f856f35e6196d", + "is_verified": false, + "line_number": 26 + } + ], + "plugins/pulsar-websocket/data/locale/en-US.ini": [ + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "fc4ea6ee08fe3cbc533c57da9b8f1c596f0b0f84", + "is_verified": false, + "line_number": 13 + }, + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "3aea0bafa7d88caff43878d346fa5ec72412ca19", + "is_verified": false, + "line_number": 14 + }, + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "983aeee5c2f5de5d44cb57e65d5d6d852c9dcdd5", + "is_verified": false, + "line_number": 20 + }, + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "cef6e514114dc92935cfcaa8d7142bc0b1e05de8", + "is_verified": false, + "line_number": 21 + }, + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "d933709caa7e5640a32cde0489420bca7f382240", + "is_verified": false, + "line_number": 22 + }, + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "cfc19ad2836f9d620796126dcca64a820a470a03", + "is_verified": false, + "line_number": 23 + }, + { + "type": "Secret Keyword", + "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", + "hashed_secret": "c49b578fbe8c74b0a9e38fe19264715719135c7a", + "is_verified": false, + "line_number": 24 + } + ], + "scripts/probe-m6-live.py": [ + { + "type": "Base64 High Entropy String", + "filename": "scripts/probe-m6-live.py", + "hashed_secret": "d3caf5e1635ee7aab32349299591eac56bcd9739", + "is_verified": false, + "line_number": 130 + } + ] + }, "generated_at": "2026-06-08T00:00:00Z" } From 84ac063765b3ff38db47c76f03446fd6f817ca72 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 00:53:49 +0200 Subject: [PATCH 07/12] ci: harden detect-secrets gate against baseline timestamp churn `detect-secrets scan --baseline` rewrites the baseline file in place and always refreshes the volatile `generated_at` timestamp, so the previous `git diff --quiet -- .secrets.baseline` check failed on the timestamp alone with zero new secrets -- a guaranteed false red on the secret-scan gate. Compare only the `results` map (filename + hashed_secret) so the gate fails strictly when a genuinely new secret-shaped finding appears, never on timestamp/metadata churn. A real new finding still turns the PR red; a removed baseline finding is surfaced as a warning to prune. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/compliance.yml | 46 ++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/workflows/compliance.yml b/.github/workflows/compliance.yml index 738e827..0d0a3a5 100644 --- a/.github/workflows/compliance.yml +++ b/.github/workflows/compliance.yml @@ -96,23 +96,41 @@ jobs: run: python -m pip install --upgrade "detect-secrets==1.5.0" - name: detect-secrets audit against baseline - # scan the tree, then diff against the committed baseline. If the - # scan surfaces any finding absent from .secrets.baseline, the - # baseline is out of date *because a new secret-shaped string - # appeared* -> fail. New legitimate allowlist entries must be added - # to the baseline in the same PR (detect-secrets scan > .secrets.baseline). + # Re-scan the tree against the committed baseline, then compare ONLY + # the `results` block (the actual findings) -- NOT the whole file -- + # against what is committed. `scan --baseline` rewrites the file in + # place and always refreshes the volatile `generated_at` timestamp + # (and may reorder plugin metadata), so a raw `git diff` on the file + # would fail on the timestamp alone with zero new secrets. By diffing + # the `results` map alone we ignore that noise and fail only when a + # finding appears that is not already in the audited baseline -- i.e. + # a genuinely new secret-shaped string. To add a legitimate new + # allowlist entry: `detect-secrets scan > .secrets.baseline`, audit, + # commit. A real secret => ROTATE + purge per security.md. run: | set -euo pipefail + # Snapshot the committed baseline OUTSIDE the repo tree so the + # re-scan below doesn't pick the copy up as a new file. + cp .secrets.baseline "${RUNNER_TEMP}/baseline.committed.json" detect-secrets scan --baseline .secrets.baseline - # `scan --baseline` updates the file in place; a non-empty diff - # means new findings were detected that were not in the baseline. - if ! git diff --quiet -- .secrets.baseline; then - echo "::error::detect-secrets found secret-shaped strings not present in .secrets.baseline." - echo "Review them. If legitimate, run 'detect-secrets scan > .secrets.baseline' and audit; if a real secret, ROTATE + purge per security.md." - git --no-pager diff -- .secrets.baseline - exit 1 - fi - echo "detect-secrets: no new findings beyond the baseline." + python - < rotate+purge; false positive => re-baseline.") + for fn, h in sorted(before_keys - after_keys): + print(f"::warning::baseline finding no longer present in {fn} (hash {h[:12]}...). Re-baseline to prune.") + sys.exit(1) + print("detect-secrets: no findings beyond the audited baseline.") + PY # ── deps-audit : npm high/critical CVE gate ────────────────────────── # Dependency scope = npm. The repo's runtime deps are the @clodocapeo/ From 59a2c09c6207a28d5aa03ff565b681be7707071c 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 01:06:56 +0200 Subject: [PATCH 08/12] ci: re-baseline detect-secrets for shifted compliance.yml false positive The previous commit reworded the comment block in compliance.yml that documents the secret-scan mechanism. The detect-secrets "Secret Keyword" plugin flags that meta-text (it talks about `hashed_secret` / secrets), a known self-referential false positive already present in the audited baseline. Editing the block shifted its line (104 -> 110) and changed its content hash, so the committed baseline went stale and the secret-scan gate correctly went red on a "new" finding. This is NOT a leaked credential: the finding is the workflow file describing how the gate hashes findings. Re-baseline so the audited false positive matches the current file; no other finding changes, and no finding is audited as a real secret. Re-scanning against the new baseline yields zero new/removed findings (gate green). Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- .secrets.baseline | 146 +++++++++++++++++++++++----------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index f95d64c..13d5edc 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -1,5 +1,45 @@ { - "version": "1.5.0", + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "filename": ".secrets.baseline", + "path": "detect_secrets.filters.common.is_baseline_file" + }, + { + "min_level": 2, + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies" + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "generated_at": "2026-06-07T23:06:38Z", "plugins_used": [ { "name": "ArtifactoryDetector" @@ -11,8 +51,8 @@ "name": "AzureStorageKeyDetector" }, { - "name": "Base64HighEntropyString", - "limit": 4.5 + "limit": 4.5, + "name": "Base64HighEntropyString" }, { "name": "BasicAuthDetector" @@ -30,8 +70,8 @@ "name": "GitLabTokenDetector" }, { - "name": "HexHighEntropyString", - "limit": 3 + "limit": 3, + "name": "HexHighEntropyString" }, { "name": "IbmCloudIamDetector" @@ -46,8 +86,8 @@ "name": "JwtTokenDetector" }, { - "name": "KeywordDetector", - "keyword_exclude": "" + "keyword_exclude": "", + "name": "KeywordDetector" }, { "name": "MailchimpDetector" @@ -86,143 +126,103 @@ "name": "TwilioKeyDetector" } ], - "filters_used": [ - { - "path": "detect_secrets.filters.allowlist.is_line_allowlisted" - }, - { - "path": "detect_secrets.filters.common.is_baseline_file", - "filename": ".secrets.baseline" - }, - { - "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", - "min_level": 2 - }, - { - "path": "detect_secrets.filters.heuristic.is_indirect_reference" - }, - { - "path": "detect_secrets.filters.heuristic.is_likely_id_string" - }, - { - "path": "detect_secrets.filters.heuristic.is_lock_file" - }, - { - "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" - }, - { - "path": "detect_secrets.filters.heuristic.is_potential_uuid" - }, - { - "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" - }, - { - "path": "detect_secrets.filters.heuristic.is_sequential_string" - }, - { - "path": "detect_secrets.filters.heuristic.is_swagger_file" - }, - { - "path": "detect_secrets.filters.heuristic.is_templated_secret" - } - ], "results": { ".github/workflows/compliance.yml": [ { - "type": "Secret Keyword", "filename": ".github/workflows/compliance.yml", - "hashed_secret": "2b81714642b5c9c647d08da3c73b5fad9ed4b260", + "hashed_secret": "2ca3d07abf093c5b73e5eedc2c8c75dc7aaf321d", "is_verified": false, - "line_number": 104 + "line_number": 110, + "type": "Secret Keyword" } ], "docs/DEVELOPMENT.md": [ { - "type": "Secret Keyword", "filename": "docs/DEVELOPMENT.md", "hashed_secret": "8cb202358b433b002379b0a492443bec0adcee99", "is_verified": false, - "line_number": 57 + "line_number": 57, + "type": "Secret Keyword" } ], "packages/pulsar-bundle-full/tests/fake-pulsar.mjs": [ { - "type": "Secret Keyword", "filename": "packages/pulsar-bundle-full/tests/fake-pulsar.mjs", "hashed_secret": "973229c027606bc8cf2cbfb6259f856f35e6196d", "is_verified": false, - "line_number": 26 + "line_number": 26, + "type": "Secret Keyword" } ], "packages/pulsar-bundle/tests/fake-pulsar.mjs": [ { - "type": "Secret Keyword", "filename": "packages/pulsar-bundle/tests/fake-pulsar.mjs", "hashed_secret": "973229c027606bc8cf2cbfb6259f856f35e6196d", "is_verified": false, - "line_number": 26 + "line_number": 26, + "type": "Secret Keyword" } ], "plugins/pulsar-websocket/data/locale/en-US.ini": [ { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "fc4ea6ee08fe3cbc533c57da9b8f1c596f0b0f84", "is_verified": false, - "line_number": 13 + "line_number": 13, + "type": "Secret Keyword" }, { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "3aea0bafa7d88caff43878d346fa5ec72412ca19", "is_verified": false, - "line_number": 14 + "line_number": 14, + "type": "Secret Keyword" }, { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "983aeee5c2f5de5d44cb57e65d5d6d852c9dcdd5", "is_verified": false, - "line_number": 20 + "line_number": 20, + "type": "Secret Keyword" }, { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "cef6e514114dc92935cfcaa8d7142bc0b1e05de8", "is_verified": false, - "line_number": 21 + "line_number": 21, + "type": "Secret Keyword" }, { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "d933709caa7e5640a32cde0489420bca7f382240", "is_verified": false, - "line_number": 22 + "line_number": 22, + "type": "Secret Keyword" }, { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "cfc19ad2836f9d620796126dcca64a820a470a03", "is_verified": false, - "line_number": 23 + "line_number": 23, + "type": "Secret Keyword" }, { - "type": "Secret Keyword", "filename": "plugins/pulsar-websocket/data/locale/en-US.ini", "hashed_secret": "c49b578fbe8c74b0a9e38fe19264715719135c7a", "is_verified": false, - "line_number": 24 + "line_number": 24, + "type": "Secret Keyword" } ], "scripts/probe-m6-live.py": [ { - "type": "Base64 High Entropy String", "filename": "scripts/probe-m6-live.py", "hashed_secret": "d3caf5e1635ee7aab32349299591eac56bcd9739", "is_verified": false, - "line_number": 130 + "line_number": 130, + "type": "Base64 High Entropy String" } ] }, - "generated_at": "2026-06-08T00:00:00Z" + "version": "1.5.0" } From 6060705375ccb3900d3131d9558833665199d186 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 01:17:20 +0200 Subject: [PATCH 09/12] fix(deps): bump ws to 8.20.1 (GHSA-58qx-3vcg-4xpx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Résout le risque résiduel R1 flaggé par Bastion : ws@8.20.0 est vulnérable (GHSA-58qx-3vcg-4xpx, modéré, CVSS 4.4), résolu en 8.20.1. Bump de la résolution lockfile uniquement — les ranges ^8.18.0 des packages workspace satisfont déjà 8.20.1, package.json inchangé. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1588309..c6303b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1537,9 +1537,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" From 03e50d83c61615640ed5d68b8336bf64dd4361fd 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 01:18:20 +0200 Subject: [PATCH 10/12] docs(adr): add ADR-001 build ATL-gate + CI compliance Record the three structural build/CI decisions landed in PR #43: ATL-conditional build-gate (PULSAR_HAVE_ATL, default ON, CI-hard throw), the compliance.yml merge gate (secret-scan/deps-audit/lockfile/codeowners), and the URL-swap scene-switch fallback. Carries the Bastion residual-risk ledger (R1 resolved via ws@8.20.1, R2 npm-only deps-audit, R3 baseline re-audit) and traces the headless multi-scene (CreateScene 204) limitation as a deferred item. First ADR of the repo; sets the house ADR format. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../001-build-atl-gate-and-ci-compliance.md | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 docs/adr/001-build-atl-gate-and-ci-compliance.md diff --git a/docs/adr/001-build-atl-gate-and-ci-compliance.md b/docs/adr/001-build-atl-gate-and-ci-compliance.md new file mode 100644 index 0000000..a2c3c90 --- /dev/null +++ b/docs/adr/001-build-atl-gate-and-ci-compliance.md @@ -0,0 +1,191 @@ +# ADR 001 — ATL build-gate & CI compliance gates + +- **Status**: proposed +- **Date**: 2026-06-08 +- **Decided**: — +- **Deciders**: @ClodoCapeo (maintainer) +- **Author**: Atlas (architect agent) +- **Supersedes**: — +- **Superseded by**: — + +--- + +## 1. Context + +Pulsar is a hard fork / patched build of OBS Studio that produces `pulsar.exe` +(see `scripts/build-win.ps1`, `patches/0001-*`, `patches/0002-*`). PR #43 +(`forge/pulsar-twitch-scene-switch`) brought the Windows build green on the +`windows-2022` CI runner, wired the org-wide étage-0 merge gate +(`docs/rules/git.md §1`, `docs/rules/security.md §Détection`) into the repo, and +shipped a 30-second live broadcast smoke test for Twitch scene switching. + +Three structural build/CI decisions were taken during that work and need to be +recorded. This is the **first ADR of the Pulsar repo** — it also sets the house +ADR format for the repo (numbered sections, `proposed` status set by Atlas, +flipped to `accepted` by Vigil). + +The decisions below are **recorded, not re-arbitrated**: they were made and +landed in PR #43. This ADR makes them traceable and carries the residual-risk +ledger that Bastion cleared. + +## 2. Decision drivers + +- **CI must stay green and unchanged for the canonical build.** The default path + (full plugin set, ATL present) must behave exactly as upstream OBS + Pulsar + patches did before PR #43. +- **A build on a machine without the VS2022 "C++ ATL" workload must not silently + produce a different binary.** Either it degrades explicitly, or it fails loud — + never an undetected partial build. +- **The étage-0 merge gate is non-negotiable.** `docs/rules/git.md §1` requires + secret scanning, dependency audit, lockfile check and CODEOWNERS check as + blocking CI, with no `continue-on-error`. +- **Single responsibility per failure surface.** Governance checks must not be + tangled with the C++ build graph so a reviewer reads `gh pr checks` cleanly. +- **The live smoke test must prove the broadcast path end-to-end** even where the + headless OBS build constrains what scene primitives are available. + +## 3. Decision + +### 3.1 Conditional ATL build-gate (`PULSAR_HAVE_ATL`, default ON) + +`scripts/build-win.ps1` detects ATL availability (via `vswhere` plus a probe of +the three `atlmfc/include` headers) and injects the CMake flag accordingly: + +| Condition | Flag injected | Plugin set | +|---|---|---| +| ATL present (CI `windows-2022`, canonical dev box) | `-DPULSAR_HAVE_ATL=ON` | full — identical to upstream/CI before PR #43 | +| ATL absent | `-DPULSAR_HAVE_ATL=OFF` | `obs-qsv11`, `win-dshow`, `virtualcam` excluded by `patches/0002-*` | + +The default is **ON**, so CI and the canonical dev environment build the full +plugin set unchanged. Only an ATL-less box takes the reduced path. + +**Hardening:** when running under CI (`$env:GITHUB_ACTIONS` / `$env:CI`), a +missing ATL is a `throw` (red build), **not** a warning. CI is contractually an +ATL-present environment; ATL absence there means the toolchain regressed and the +run must fail rather than silently ship a reduced binary. The operator-facing +diagnosis and recovery for the local (non-CI) failure is documented in +`docs/runbooks/atl-missing-build-failure.md`. + +### 3.2 CI compliance gates (`.github/workflows/compliance.yml`) + +A workflow **separate** from `pipeline.yml` (governance vs build — different +concern, cheaper ubuntu runners, independent failure surface) carries the +étage-0 merge gate as four blocking jobs, plus the ownership file: + +| Job | Check | Source rule | +|---|---|---| +| `secret-scan` | trufflehog (fs + git history) + detect-secrets against `.secrets.baseline` | `security.md §Détection` | +| `deps-audit` | `npm audit --omit=dev --audit-level=high` | `git.md §1`, `security.md §Détection` | +| `lockfile-check` | `package-lock.json` in sync | `git.md §1` | +| `codeowners-check` | `.github/CODEOWNERS` valid | `git.md §1` | + +Plus `.github/CODEOWNERS` itself (maintainer-only on governance/CI/licence paths, +catch-all elsewhere). **No `continue-on-error` anywhere** — every job is allowed +and intended to turn the PR red and block the merge, per `git.md`. + +### 3.3 Scene-switch live test uses URL-swap (not OBS multi-scene) + +The 30-second live broadcast smoke test switches "scenes" via a **URL-swap +fallback** (`scripts/live-test/scene-a.html` ↔ `scene-b.html`) rather than real +OBS multi-scene orchestration. The headless build **declines `CreateScene` +(returns code 204)** over obs-websocket, so true multi-scene OBS switching is not +available in the headless context. The URL-swap proves the broadcast path +(encode → ingest → Twitch) end-to-end without depending on a scene primitive the +headless build refuses. The underlying multi-scene limitation is a **deferred +architecture item** — see §5 / Deferred, not resolved here. + +## 4. Consequences + +- **Canonical build unchanged.** ATL present → full plugin set, byte-for-byte the + same flags as before PR #43; CI behaviour is unchanged. +- **Reduced builds are explicit and reproducible.** An ATL-less box gets a + documented, flagged subset (`obs-qsv11` / `win-dshow` / `virtualcam` excluded), + never a silent partial binary; CI can never accidentally ship that subset + (it `throw`s instead). +- **Merge gate is enforced in-repo.** Every PR to `main` runs the four compliance + jobs; a leaked secret, a `high`+ npm CVE, a lockfile drift or a broken + CODEOWNERS each independently blocks the merge. +- **Build vs governance failures are decoupled.** A red `secret-scan` does not + entangle the C++ build graph and vice versa; the reviewer sees exactly which + gate broke. +- **Live test is honest about its scope.** It proves the broadcast pipeline, not + OBS scene graph manipulation — which is correctly flagged as future work. + +## 5. Risks + +### Residual risks accepted (Bastion clearance) + +- **R2 — deps-audit is npm-only.** The Python probes (`websockets`) are dev-only, + not shipped, and have no pinned lockfile, so they sit outside `pip-audit` by + choice. **Guard:** as soon as a `requirements.txt` / `uv.lock` is committed to + this repo, a `pip-audit` job MUST be added to `compliance.yml`. Assumed blind + spot until then. +- **R3 — `trufflehog --only-verified` + baseline dependency.** Verified-only + trufflehog catches credentials it can validate against a live service; + non-verifiable secrets are only caught by detect-secrets against + `.secrets.baseline`. **Guard:** every future addition to `.secrets.baseline` + MUST be re-audited (Bastion clearance) before commit — the baseline is a + suppression surface and must not grow unreviewed. + +### Resolved during this work + +- **R1 — `ws@8.20.0`** (GHSA-58qx-3vcg-4xpx, moderate, CVSS 4.4, memory + info-disclosure). Below the `high` threshold of the `deps-audit` gate, so it + would not have blocked the merge. **Resolved** by bumping to `ws@8.20.1` + (parallel Forge commit, Refs #43); `package-lock.json` now pins `ws@8.20.1`. + +### Deferred (no decision taken here) + +- **Headless multi-scene limitation.** The headless OBS build declines + `CreateScene` (code 204), so there is no real OBS multi-scene switching in the + headless context — only the URL-swap fallback (§3.3). This is **traced, not + decided**: if/when true multi-scene OBS switching becomes a requirement, it + warrants its own ADR (root-cause the 204 — headless module load order, plugin + set, or a `PULSAR_HAVE_ATL=OFF` interaction — and choose a path). Not in scope + for ADR-001. + +> Security-classed risks are owned by Bastion. R2 and R3 above are accepted with +> their guards as written by Bastion; no further security risk is opened by this +> ADR. + +## 6. Resolution criteria + +1. `compliance.yml` runs on every PR to `main` with the four jobs + (`secret-scan`, `deps-audit`, `lockfile-check`, `codeowners-check`) and **no + `continue-on-error`**; each can independently red the PR. +2. The canonical build (ATL present, CI `windows-2022`) produces the full plugin + set, with flags identical to pre-PR-#43. +3. An ATL-absent build emits `-DPULSAR_HAVE_ATL=OFF`, excludes the three + ATL-dependent plugins via `patches/0002-*`, and — **under CI only** — `throw`s + instead of warning. +4. `package-lock.json` pins `ws@8.20.1` (R1 closed); `npm audit --omit=dev + --audit-level=high` is clean. +5. `.github/CODEOWNERS` exists and passes `codeowners-check`. +6. The deferred multi-scene item is recorded (this §5) and not silently treated + as done. + +## 7. Alternatives considered (rejected) + +- **No ATL gate — require the C++ ATL workload unconditionally.** Rejected: hard + toolchain dependency that breaks any contributor box lacking the workload, with + a cryptic C1083 instead of a flagged, documented degradation. +- **ATL absent = warning everywhere (incl. CI).** Rejected: CI would silently + ship a reduced binary on a toolchain regression. CI is contractually + ATL-present, so absence there is an error (`throw`), not a warning. +- **One CI workflow for build + governance.** Rejected: tangles a red secret-scan + with the C++ build graph, slows governance behind MSVC/Windows runners, and + muddies `gh pr checks`. Split into `pipeline.yml` (build) + `compliance.yml` + (governance). +- **Implement real OBS multi-scene for the live test.** Rejected for PR #43: the + headless build declines `CreateScene` (204); resolving it is out of scope and + deferred to a future ADR (§5). URL-swap proves the broadcast path today. + +## 8. References + +- PR #43 — `forge/pulsar-twitch-scene-switch`. +- `scripts/build-win.ps1` — ATL detection + flag injection. +- `patches/0002-build-gate-ATL-dependent-plugins-behind-PULSAR_HAVE_ATL.patch`. +- `.github/workflows/compliance.yml`, `.github/CODEOWNERS`, `.secrets.baseline`. +- `docs/runbooks/atl-missing-build-failure.md` — local ATL-missing recovery. +- `docs/rules/git.md §1` (merge gate), `docs/rules/security.md §Détection` + (secret scanning / deps audit). From 4d55a8e5c978159f9511aaf27cc523c29f3956fd 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 01:21:08 +0200 Subject: [PATCH 11/12] docs(adr): accept ADR-001 Vigil review of PR #43 delta (ws@8.20.1 bump + ADR-001) passed: ws bump is lockfile-only (3 lines, 8.20.0->8.20.1), package.json ranges ^8.18.0 unchanged, no leftover 8.20.0 in lock (R1 closed). ADR-001 format sound (sets house format), zero drift vs artefacts: 3.1 matches build-win.ps1 (PULSAR_HAVE_ATL default ON, CI throw), 3.2 matches compliance.yml four jobs + CODEOWNERS, 3.3 matches scene-a/scene-b.html URL-swap. R2/R3 carry guards, headless multi-scene deferred. Flip Status proposed->accepted, set Decided 2026-06-08. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/adr/001-build-atl-gate-and-ci-compliance.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/adr/001-build-atl-gate-and-ci-compliance.md b/docs/adr/001-build-atl-gate-and-ci-compliance.md index a2c3c90..24c8576 100644 --- a/docs/adr/001-build-atl-gate-and-ci-compliance.md +++ b/docs/adr/001-build-atl-gate-and-ci-compliance.md @@ -1,8 +1,8 @@ # ADR 001 — ATL build-gate & CI compliance gates -- **Status**: proposed +- **Status**: accepted - **Date**: 2026-06-08 -- **Decided**: — +- **Decided**: 2026-06-08 - **Deciders**: @ClodoCapeo (maintainer) - **Author**: Atlas (architect agent) - **Supersedes**: — From 16fb6c8ebe838334b2dfacd020ec7d5f746ac535 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 01:46:34 +0200 Subject: [PATCH 12/12] test(live): poll out StartDestination boot race on Twitch probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live-broadcast probe called StartDestination ~3-4s after spawning pulsar.exe and gave up on the first attempt. The frontend streaming output is wired asynchronously after boot, so a fast probe can hit a transient 'frontend streaming output unavailable' before the output exists — a boot-ordering race, not a broadcast failure (the same binary/key passes on rerun, proven by re-running run 27107906022). Poll StartDestination for a bounded 20s budget, but ONLY while the error is exactly that transient string. Any other error (bad key, RTMP reject) still fails on the first attempt, and exhausting the budget is a hard failure — no masking of a genuine defect. Refs #43 Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/probe-twitch-live.py | 54 ++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/scripts/probe-twitch-live.py b/scripts/probe-twitch-live.py index 51d0a83..3320bed 100644 --- a/scripts/probe-twitch-live.py +++ b/scripts/probe-twitch-live.py @@ -88,6 +88,21 @@ POLL_INTERVAL_SEC = 5.0 DESTINATION_NAME = "pulsar-live-test" +# StartDestination can race the engine boot : the frontend streaming +# output is wired asynchronously after pulsar.exe spawns, and a probe +# that reaches StartDestination within a few seconds of boot can hit a +# transient `frontend streaming output unavailable` before the output +# exists. This is a boot-ordering race, not a broadcast failure (same +# binary/key passes on retry). We poll StartDestination for a bounded +# budget, but ONLY while the error is exactly that transient string — +# any other error (bad key, RTMP reject, etc.) fails immediately, and +# exhausting the budget is a hard failure. No masking : a genuinely +# broken streaming path never produces this exact transient and would +# still fail. +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 + # Benign log substrings that do not constitute failure. BENIGN_LOG_SUBSTRINGS = [ "no target (set PULSAR_CAPTURE_WINDOW)", # frontend-stub default boot warning @@ -616,17 +631,40 @@ async def probe(stream_key: str, duration_sec: int, fps: int) -> int: return 1 print(f"[live-test] destination created : id={dest_id}") - # 3. StartDestination. - r = await vendor_call(ws, inbox, "start-dest", "pulsar", - "StartDestination", {"id": dest_id}) - dump_response("start-dest", r) - sd = vendor_response_data(r) - if not sd.get("started"): + # 3. StartDestination — poll out the boot race (see + # START_DEST_BOOT_ERROR note above). Only the exact + # transient boot error is retried ; everything else fails + # on the first attempt. + deadline = time.time() + START_DEST_RETRY_BUDGET + attempt = 0 + while True: + attempt += 1 + r = await vendor_call(ws, inbox, f"start-dest-{attempt}", + "pulsar", "StartDestination", {"id": dest_id}) + sd = vendor_response_data(r) + if sd.get("started"): + break + err = str(sd.get("error", "")) + transient = (err == START_DEST_BOOT_ERROR) + if transient and time.time() < deadline: + print(f"[live-test] start-dest attempt #{attempt} : " + f"streaming output not ready yet " + f"('{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. + dump_response("start-dest", r) status = vendor_request_status(r) + reason = ("boot race unresolved after " + f"{START_DEST_RETRY_BUDGET}s ({attempt} attempts)" + if transient else "not started") fail_log("start-dest", - f"not started ; requestStatus={status} responseData={sd}") + f"{reason} ; requestStatus={status} responseData={sd}") return 1 - print(f"[live-test] destination STARTED -- going live") + dump_response("start-dest", r) + print(f"[live-test] destination STARTED -- going live " + f"(attempt #{attempt})") # 3b. StartRecord -- record the broadcast locally so the CI # workflow can upload the MP4 as the live-test proof. Standard