diff --git a/control-plane/app/agent_console.py b/control-plane/app/agent_console.py index d7a063d..e07825b 100644 --- a/control-plane/app/agent_console.py +++ b/control-plane/app/agent_console.py @@ -40,14 +40,23 @@ location.origin`). Mounted under `/agents//` on the portal origin, every one of those requests would leave the prefix and land on the chat surface at the origin root. -Two rewrites fix that, and both are deliberately about URLS ONLY — nothing here interprets +Three rewrites fix that, and all are deliberately about URLS ONLY — nothing here interprets or rewrites the agent's content: 1. the entry document's root-absolute `src=`/`href=` gain the prefix, so the bundle and its stylesheet load from under it (and any later `import()` chunk resolves relative to that, for free); 2. a small inline shim prefixes same-origin, root-absolute URLs passed to `fetch`, - `XMLHttpRequest`, `EventSource` and `WebSocket`. + `XMLHttpRequest`, `EventSource` and `WebSocket`; + 3. the same shim strips the prefix from `location.pathname` before the bundle runs and + re-adds it after, because the console's ROUTER also assumes the origin root: it reads + the path to pick a view, so under the prefix it matches nothing and renders an empty + `
` — the whole page blank but for its toolbar. Stripping it for the router while + keeping the address bar prefixed (so a reload still lands here) is the routing analogue + of what (2) does for requests. + +The injected inline script the CSP must then admit by hash covers all of (2) and (3); +`_shim_js` is the single source of those bytes. The shim is written against web platform APIs rather than against opencode's internals for the reason the alternative fails: the identifiers in a 3 MB minified bundle change on every @@ -66,6 +75,7 @@ """ import base64 +import hashlib import re import httpx @@ -128,9 +138,24 @@ def _shim(name: str) -> bytes: (`WebSocket.OPEN`), `instanceof` and the prototype chain all survive untouched; the console reads `WebSocket.OPEN` and a hand-rolled wrapper would drop it. """ + return (b"") + + +def _shim_js(name: str) -> str: + """The shim's JavaScript, WITHOUT the `" - ).encode() + # ROUTING. Rewriting the console's network URLs is not enough: opencode's console is + # a single-page app whose router reads `location.pathname` and matches it against + # root-relative routes, because — like its asset and server URLs — it assumes it is + # served at the ORIGIN ROOT. Mounted under this prefix, the initial path matches no + # route and the app renders an empty `
` — a "mostly blank page" with only its + # toolbar. So, before the bundle runs, strip the prefix so the router initialises at + # root; then keep every navigation the app makes prefixed in the address bar, and + # restore the prefix once the router has read the initial path — so a reload lands + # back on the console and not on the chat surface at the origin root. Verified in a + # real browser against the daemon: without this `
` is empty; with it the home + # view renders and survives a reload (enterpriseaiframework-f4c). + "function strip(u){var s=String(u);" + "if(s.indexOf(P+'/')===0)return s.slice(P.length);if(s===P)return '/';return s;}" + "function hp(u){if(u==null)return u;var s=String(u);" + "if(s.charAt(0)==='/'&&s.charAt(1)!=='/'&&!mine(s))return P+s;return s;}" + "var ops=history.pushState,ors=history.replaceState;" + "if(location.pathname.indexOf(P)===0)" + "ors.call(history,history.state,'',strip(location.pathname)+location.search+location.hash);" + "history.pushState=function(a,b,u){return ops.call(this,a,b,arguments.length>2?hp(u):u);};" + "history.replaceState=function(a,b,u){return ors.call(this,a,b,arguments.length>2?hp(u):u);};" + "window.addEventListener('DOMContentLoaded',function(){var p=location.pathname;" + "if(p.indexOf(P)!==0)ors.call(history,history.state,'',hp(p)+location.search+location.hash);});" + "})();" + ) + + +def _shim_csp_source(name: str) -> str: + """The CSP `script-src` token that authorises this agent's injected shim. + + A hash source, not `'unsafe-inline'`: the shim is one known script, so it is admitted + by exactly the mechanism opencode admits its own — `'sha256-'` over the script's + text content — and nothing else inline is permitted. The digest is over `_shim_js`'s + exact bytes, which are what `_shim` injects between the tags.""" + digest = hashlib.sha256(_shim_js(name).encode()).digest() + return "'sha256-" + base64.b64encode(digest).decode() + "'" + + +def _authorise_shim_in_csp(csp: str, name: str) -> str: + """Add the shim's hash to a forwarded CSP so the browser will run the injected script. + + The console's own CSP is preserved in full — this only widens `script-src` by the one + hash. If the policy has no `script-src`, scripts fall back to `default-src`, so an + explicit `script-src` is added that mirrors that fallback and adds the hash; without a + `default-src` either, a minimal `'self'`-plus-hash is used. Idempotent. + """ + source = _shim_csp_source(name) + directives = [d.strip() for d in csp.split(";") if d.strip()] + default_src = None + for i, d in enumerate(directives): + parts = d.split() + key = parts[0].lower() + if key == "default-src": + default_src = parts[1:] + if key == "script-src": + if source not in parts: + directives[i] = d + " " + source + return "; ".join(directives) + fallback = default_src if default_src is not None else ["'self'"] + directives.append("script-src " + " ".join(fallback + [source])) + return "; ".join(directives) def _js_string(value: str) -> str: @@ -262,6 +345,14 @@ async def agent_console_proxy(name: str, path: str, request: Request, finally: await upstream.aclose() await client.aclose() + # The entry document is the one response that gains an inline script (the shim). + # If the daemon guards the console with a CSP — opencode does — that script has to + # be admitted by hash, or the browser drops it and the console never rewrites its + # own URLs. Only the entry document is touched, because it is the only response the + # shim is injected into. + for key in list(out_headers): + if key.lower() == "content-security-policy": + out_headers[key] = _authorise_shim_in_csp(out_headers[key], name) return Response( content=_rewrite_entry_document(content, name), status_code=upstream.status_code, diff --git a/control-plane/app/portal_static/app.js b/control-plane/app/portal_static/app.js index 293d185..e0b90a0 100644 --- a/control-plane/app/portal_static/app.js +++ b/control-plane/app/portal_static/app.js @@ -135,6 +135,9 @@ function showTab(which) { if (which === "agents") { loadAgents(); } else { + // Leaving the Agents tab: no view to update, so stop the status poll until it is shown + // again (loadAgents on the next switch back restarts it if anything is still starting). + stopAgentsPoll(); loadFrame(which); // Tell the frame to re-measure once it is actually on screen. A frame laid out while // its tab was hidden measures zero width, and ttyd sizes its terminal to whatever it @@ -152,6 +155,14 @@ function showTab(which) { $("tab-chat").addEventListener("click", () => showTab("chat")); $("tab-code").addEventListener("click", () => showTab("code")); $("tab-agents").addEventListener("click", () => showTab("agents")); + +// A backgrounded browser tab throttles timers to a crawl, so the status poll effectively +// stalls while the window is hidden. On return, refetch once if the Agents tab is the one +// showing — that both refreshes what went stale and restarts the poll if anything is still +// coming up. +document.addEventListener("visibilitychange", () => { + if (!document.hidden && agentsTabActive()) loadAgents(); +}); $("code-retry").addEventListener("click", () => { const f = $("frame-code"); delete f.dataset.loaded; @@ -457,6 +468,31 @@ function hours(n) { let AGENTS_BUSY = false; +// A pod goes `starting` -> `running` with nothing on this page to trigger a refetch: the +// list reloads only on a tab switch or an action (see the design note at showTab). So an +// agent the user just created — or just wired a connector to — sits at "starting…" until +// they navigate away and back, a working boot that reads as a stuck one. That is exactly +// the finding-43 failure mode. While the Agents tab is on screen and any agent is still in +// a transient state, re-poll until it settles; stop the moment nothing is transitioning, +// the tab is hidden, or the browser tab is backgrounded. +const AGENTS_POLL_MS = 4000; +let agentsPollTimer = null; + +function agentsTabActive() { + return !$("view-agents").hidden; +} + +function stopAgentsPoll() { + if (agentsPollTimer) { clearTimeout(agentsPollTimer); agentsPollTimer = null; } +} + +function scheduleAgentsPoll(rows) { + stopAgentsPoll(); + const transitioning = rows.some((a) => a.status === "starting"); + if (!transitioning || !agentsTabActive() || document.hidden) return; + agentsPollTimer = setTimeout(loadAgents, AGENTS_POLL_MS); +} + async function loadAgents() { let d; try { d = await get("/portal/api/agents"); } @@ -464,6 +500,12 @@ async function loadAgents() { $("agents-error").hidden = false; $("agents-error").textContent = "Could not read your agents just now. The list below may be out of date."; + // A blip while an agent was still coming up must not freeze the view at "starting…" — + // keep retrying on the same cadence as long as the tab is the one being watched. + stopAgentsPoll(); + if (agentsTabActive() && !document.hidden) { + agentsPollTimer = setTimeout(loadAgents, AGENTS_POLL_MS); + } return; } $("agents-error").hidden = !d.usage_error; @@ -492,6 +534,8 @@ async function loadAgents() { const rows = d.agents || []; for (const a of rows) list.appendChild(agentRow(a)); $("agents-empty").hidden = rows.length !== 0; + + scheduleAgentsPoll(rows); } function agentRow(a) { diff --git a/control-plane/tests/test_agent_console.py b/control-plane/tests/test_agent_console.py index 55501a8..1ac9f39 100644 --- a/control-plane/tests/test_agent_console.py +++ b/control-plane/tests/test_agent_console.py @@ -83,6 +83,20 @@ BUNDLE = b'console.log("the opencode console bundle");' +# opencode guards the console with a strict CSP whose `script-src` has NO 'unsafe-inline': +# it whitelists its own one inline script by sha256 and nothing else. The proxy injects the +# URL-rewriting shim as an inline script, so it must extend this policy with the shim's own +# hash or the browser drops the shim and every runtime request escapes the prefix. This is +# the real header, byte-for-byte, so the forwarding + authorising path is exercised for +# real. (enterpriseaiframework-f4c — the broken agent console.) +DAEMON_CSP = ( + "default-src 'self'; " + "script-src 'self' 'wasm-unsafe-eval' " + "'sha256-jURJv6M3UYb5GqNE3+c1I0SvGlqS1+LmHVWtqFsefBk='; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; " + "font-src 'self' data:; media-src 'self' data:; connect-src * data:" +) + class FakeDaemon: """A stand-in for the RESIDENT `opencode serve`, with the properties that matter. @@ -138,10 +152,12 @@ def _handler(daemon): # noqa: N805 - the closure IS the handler's access to the class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" - def _send(self, status, ctype, body: bytes, close=False): + def _send(self, status, ctype, body: bytes, close=False, csp=None): self.send_response(status) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body))) + if csp: + self.send_header("Content-Security-Policy", csp) if close: self.send_header("Connection", "close") self.end_headers() @@ -161,7 +177,8 @@ def _dispatch(self, method): return if path in ("/", "/app"): - self._send(200, "text/html; charset=utf-8", ENTRY_DOCUMENT) + self._send(200, "text/html; charset=utf-8", ENTRY_DOCUMENT, + csp=DAEMON_CSP) elif path == "/assets/index-CgMYRCpN.js": self._send(200, "text/javascript", BUNDLE) elif path == "/pid": @@ -378,6 +395,97 @@ def test_the_owner_attaches_and_the_console_is_served_under_its_own_prefix(world assert "authorization" not in {k.lower() for k in alice.headers} +def test_the_injected_shim_is_authorised_by_the_forwarded_csp(world): + """The console's CSP must be widened to admit the shim, or the browser drops it. + + opencode serves a `script-src` with no `'unsafe-inline'`. The proxy injects the + URL-rewriting shim as an inline script; unless the policy carries that script's own + sha256, the browser refuses to run it and — with the shim dead — every runtime request + resolves against the origin ROOT instead of the console's prefix, so `/api/event` and + the pty socket 404 in a reconnect loop and the console is a broken shell. This is the + exact defect a dogfood user hit. + + The hash is checked against the ACTUAL bytes injected, the way a browser computes it, so + the test fails if the shim text and the authorised hash ever drift apart. + """ + import base64 + import hashlib + import re as _re + + from app import agent_console + + world.add_agent("alice", "scraper") + alice = app_client("alice") + page = alice.get("/agents/scraper/app") + assert page.status_code == 200, page.text + + csp = page.headers.get("content-security-policy") + assert csp, "the console's CSP must be forwarded, not dropped" + + # The exact inline script the browser will try to run, and the hash it will demand. + injected = agent_console._shim_js("scraper") + served = page.text + assert "" in served, ( + "the authorised hash must be of the same bytes actually injected into the page" + ) + want = "'sha256-" + base64.b64encode(hashlib.sha256(injected.encode()).digest()).decode() + "'" + + script_src = next( + d.strip() for d in csp.split(";") if d.strip().lower().startswith("script-src") + ) + assert want in script_src.split(), ( + f"script-src must carry the injected shim's hash {want}\n script-src: {script_src}" + ) + # opencode's own policy is preserved, not replaced: its inline-script hash and the rest + # of the directives still stand. + assert "'sha256-jURJv6M3UYb5GqNE3+c1I0SvGlqS1+LmHVWtqFsefBk='" in csp + assert "style-src 'self' 'unsafe-inline'" in csp + assert "'unsafe-inline'" not in script_src, ( + "the shim must be admitted by hash, not by opening script-src to all inline scripts" + ) + + +def test_the_shim_teaches_the_router_its_prefix(world): + """Rewriting network URLs is not enough — the console's ROUTER also assumes the root. + + opencode's console is a single-page app whose router reads `location.pathname` to pick a + view. Mounted under `/agents//`, the initial path matches no route and the app + renders an empty `
` — the whole page blank but for its toolbar (a dogfood user's + "mostly blank page"). So the shim strips the prefix before the bundle runs, so the router + initialises at root, and keeps the address bar prefixed so a reload lands back here. This + pins that wiring: the strip on load, the history interposition, and the re-add after load. + """ + world.add_agent("alice", "scraper") + page = app_client("alice").get("/agents/scraper/app") + body = page.text + assert "history.pushState=function" in body and "history.replaceState=function" in body, ( + "the shim must interpose on the History API so navigations stay under the prefix" + ) + assert "location.pathname.indexOf(P)===0" in body, ( + "the shim must strip the prefix on load so the router initialises at root, or the " + "console renders an empty
(enterpriseaiframework-f4c)" + ) + assert "DOMContentLoaded" in body, ( + "the shim must re-add the prefix after load so a reload lands on the console, not the " + "chat surface at the origin root" + ) + + +def test_csp_authorisation_falls_back_to_default_src_when_no_script_src(): + """A daemon whose CSP has no explicit `script-src` lets scripts fall back to + `default-src`. Authorising the shim must then ADD a `script-src` that keeps that + fallback and adds the hash — not silently leave the shim unauthorised.""" + from app import agent_console + + src = agent_console._shim_csp_source("bot") + out = agent_console._authorise_shim_in_csp("default-src 'self' https://cdn.example", "bot") + script = next(d.strip() for d in out.split(";") if d.strip().lower().startswith("script-src")) + parts = script.split() + assert "'self'" in parts and "https://cdn.example" in parts and src in parts, out + # And the original default-src is untouched. + assert "default-src 'self' https://cdn.example" in out + + def test_a_percent_encoded_path_reaches_the_daemon_unchanged(world): """`/api/fs/read/*` carries an encoded absolute file path in its wildcard. diff --git a/deploy/bin/kaniko-build.sh b/deploy/bin/kaniko-build.sh index 1bbfef7..e99cd20 100755 --- a/deploy/bin/kaniko-build.sh +++ b/deploy/bin/kaniko-build.sh @@ -36,8 +36,12 @@ if (( SIZE > 700000 )); then fi echo "==> context ${CONTEXT} -> ${SIZE} bytes" -kubectl -n "$NS" create configmap "$CM" --from-file=context.tar.gz="$TARBALL" \ - --dry-run=client -o yaml | kubectl apply -f - >/dev/null +# `create`, not `apply`: apply stores a copy of the object in a +# `last-applied-configuration` annotation, and annotations cap at 256KiB — so a context +# larger than that fails on the annotation long before the ~1MiB ConfigMap limit this +# script actually guards against (289KB control-plane build, enterpriseaiframework-f4c). +# The name is unique per build, so there is nothing to reconcile and nothing to apply over. +kubectl -n "$NS" create configmap "$CM" --from-file=context.tar.gz="$TARBALL" >/dev/null BUILD_ARGS="" for a in "$@"; do diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index c50ca80..3914aa4 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -22,11 +22,27 @@ :8081 { # Identity and the chat surface must share one public origin, or the OIDC issuer # check fails. Keycloak owns these paths; the chat surface owns everything else. + # + # This block is plain HTTP — Tailscale Funnel terminated the browser's TLS on :8443 + # before it reached here, so Caddy would otherwise forward `X-Forwarded-Proto: http`. + # Keycloak then treats the request as non-secure and strips Secure/SameSite=None from + # its SSO cookies (AUTH_SESSION_ID, KEYCLOAK_IDENTITY, ...). Those cookies are then not + # sent in the account console's redirect-based re-auth, so check-sso never finds a + # session and the console reloads itself forever (enterpriseaiframework-4f1). LibreChat + # and the portal oauth2-proxy dodged this by turning Secure off app-side, but the + # account console *needs* SameSite=None (which requires Secure), so the only correct fix + # for it is to assert the real public scheme here. The browser's connection to Funnel + # really is TLS, so this header is truthful. Only the :8081 block needs it; the :8443 + # LAN block below terminates its own TLS and already reports https. handle /realms/* { - reverse_proxy 192.168.2.44:30382 + reverse_proxy 192.168.2.44:30382 { + header_up X-Forwarded-Proto https + } } handle /resources/* { - reverse_proxy 192.168.2.44:30382 + reverse_proxy 192.168.2.44:30382 { + header_up X-Forwarded-Proto https + } } # Published work. Deliberately NOT behind any auth: the audience is parents, who have # no account. Static files only, served by a plain web server with no interpreter and diff --git a/tests/test_agents_status_poll.py b/tests/test_agents_status_poll.py new file mode 100644 index 0000000..9607d5a --- /dev/null +++ b/tests/test_agents_status_poll.py @@ -0,0 +1,77 @@ +"""The Agents view must re-poll a still-starting agent until it settles — enterpriseaiframework-7af. + +An agent's pod goes `starting` -> `running` with nothing on the portal to trigger a refetch: +the list is reloaded only on a tab switch or an explicit action. So an agent the user just +created — or just wired a connector to — sits at "starting…" forever until they navigate away +and back. A working boot that reads as a stuck one is the finding-43 failure mode, and it is +exactly what a dogfood user hit ("stuck in starting... or the UI isn't updating"). + +The fix is a bounded poll in `control-plane/app/portal_static/app.js`: while the Agents tab is +on screen and any agent is still transitioning, refetch until nothing is; stop when the tab is +left or the browser tab is backgrounded. These are static checks on that wiring — the behaviour +of the poll predicate is exercised directly in `test_agents_status_poll_logic.py` under Node. +What this file guards is the realistic regression: someone deleting the poll, or unbounding it +so it hammers the API on a hidden tab. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +APP_JS = REPO / "control-plane" / "app" / "portal_static" / "app.js" + + +def _js() -> str: + return APP_JS.read_text() + + +def test_loadagents_schedules_a_repoll(): + js = _js() + assert "scheduleAgentsPoll(rows)" in js, ( + "loadAgents() must call scheduleAgentsPoll(rows) after rendering, or a starting agent " + "never refreshes to running without a manual tab switch (finding-43 failure mode)" + ) + + +def test_the_poll_only_runs_while_something_is_transitioning(): + js = _js() + m = re.search(r"function scheduleAgentsPoll\([^)]*\)\s*\{(.*?)\n\}", js, re.DOTALL) + assert m, "scheduleAgentsPoll must exist" + body = m.group(1) + assert 'a.status === "starting"' in body, ( + "the poll must key on a transient status (starting) so it STOPS once every agent is " + "running or stopped — an unconditional poll would hammer the API forever" + ) + assert "setTimeout(loadAgents" in body, "the poll must re-invoke loadAgents on a timer" + + +def test_the_poll_is_bounded_to_the_visible_agents_tab(): + js = _js() + body = re.search(r"function scheduleAgentsPoll\([^)]*\)\s*\{(.*?)\n\}", js, re.DOTALL).group(1) + assert "agentsTabActive()" in body and "document.hidden" in body, ( + "the poll must not run when the Agents tab is not showing or the browser tab is " + "backgrounded — otherwise it keeps polling behind the user's back" + ) + + +def test_leaving_the_agents_tab_stops_the_poll(): + js = _js() + # In showTab, the non-agents branch must clear the timer. + m = re.search(r"function showTab\([^)]*\)\s*\{(.*?)\n\}", js, re.DOTALL) + assert m and "stopAgentsPoll()" in m.group(1), ( + "showTab must stopAgentsPoll() when switching away from the Agents tab" + ) + + +def test_returning_to_the_browser_tab_refreshes(): + js = _js() + assert re.search( + r'addEventListener\(\s*["\']visibilitychange["\'].*?agentsTabActive\(\).*?loadAgents\(\)', + js, + re.DOTALL, + ), ( + "a visibilitychange handler must refetch when the tab becomes visible again, because a " + "backgrounded tab throttles the poll timer to a stall" + ) diff --git a/tests/test_caddy_keycloak_forwarded_proto.py b/tests/test_caddy_keycloak_forwarded_proto.py new file mode 100644 index 0000000..2d39239 --- /dev/null +++ b/tests/test_caddy_keycloak_forwarded_proto.py @@ -0,0 +1,71 @@ +"""The Funnel-fronted origin block must tell Keycloak the true public scheme — enterpriseaiframework-4f1. + +WHY THIS EXISTS. Browsers reach this stack over TLS on :8443, but Tailscale Funnel terminates +that TLS and forwards plain HTTP to Caddy's `:8081` block. Caddy therefore reports +`X-Forwarded-Proto: http` to whatever it proxies unless told otherwise. Keycloak, seeing a +"non-secure" request, drops `Secure` (and with it `SameSite=None`) from its SSO cookies. The +account console's redirect-based re-authentication then never gets its session cookie back, so +it bounces through the auth endpoint endlessly — a visible reload loop the moment a user clicks +"Account & password". + +Verified live when the fix went in: with `X-Forwarded-Proto: https` forwarded, Keycloak's +`/protocol/openid-connect/auth` sets `AUTH_SESSION_ID=...;Secure;SameSite=None`; without it, the +same request sets `SameSite=Lax` and no `Secure`, and Keycloak logs "Non-secure context +detected". What this file guards is the realistic regression — someone reverting the `:8081` +Keycloak routes to a bare `reverse_proxy`, which silently reintroduces the loop. + +The `:8443` LAN block is deliberately NOT required to carry the header: it terminates its own +TLS, so Caddy already reports https there, and browsers never hit it (it is the in-cluster OIDC +backchannel). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +CADDYFILE = REPO / "deploy" / "caddy" / "Caddyfile" + + +def _block_8081() -> str: + """The plain-HTTP `:8081` origin listener that Funnel forwards browsers to. + + It opens with a bare `:8081 {` and runs until the next top-level listener, which begins + with `https://` at column 0. Everything between is this block's body. + """ + text = CADDYFILE.read_text() + m = re.search(r"(?m)^:8081 \{\n(.*?)\n(?=^\S)", text, re.DOTALL) + assert m, "could not locate the :8081 origin block in the Caddyfile" + return m.group(1) + + +def _handle_body(block: str, path: str) -> str: + """The body of a single-level `handle { ... }` — no nested braces inside these.""" + m = re.search(r"handle " + re.escape(path) + r"\s*\{(.*?)\n \}", block, re.DOTALL) + assert m, f"no `handle {path}` found in the :8081 block" + return m.group(1) + + +def test_keycloak_routes_assert_https_scheme_to_the_pod(): + block = _block_8081() + for path in ("/realms/*", "/resources/*"): + body = _handle_body(block, path) + assert re.search(r"header_up\s+X-Forwarded-Proto\s+https", body), ( + f"the :8081 `handle {path}` must assert `header_up X-Forwarded-Proto https`. " + "Funnel terminates TLS before this plain-HTTP block, so without it Keycloak sees " + "http, drops Secure/SameSite=None from its SSO cookies, and the account console " + "reload-loops (enterpriseaiframework-4f1)." + ) + + +def test_the_scheme_asserted_is_https_not_http(): + """A copy-paste of the plain block that forwards `http` would be worse than nothing — + it would pin the broken behaviour. The asserted scheme must be https.""" + block = _block_8081() + for path in ("/realms/*", "/resources/*"): + body = _handle_body(block, path) + forwarded = re.findall(r"header_up\s+X-Forwarded-Proto\s+(\S+)", body) + assert forwarded == ["https"], ( + f"the :8081 `handle {path}` must forward X-Forwarded-Proto=https, got {forwarded}" + )