From 6e32b2086f04f2dd50fa07fbf8fe92a9c229e201 Mon Sep 17 00:00:00 2001 From: alice Date: Mon, 10 Aug 2026 17:43:01 +0000 Subject: [PATCH 1/5] Fix account console reload loop: assert https scheme to Keycloak at the Funnel-fronted edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking "Account & password" dropped users into Keycloak's account console, which then reload-looped endlessly. Root cause: the gateway VM's Caddy :8081 block — the plain-HTTP block Tailscale Funnel forwards browsers to after terminating their TLS — reported X-Forwarded-Proto: http to Keycloak. KC treated the request as non-secure and stripped Secure/SameSite=None from its SSO cookies (AUTH_SESSION_ID, KEYCLOAK_IDENTITY, ...). Those cookies were then not sent in the account console's redirect-based re-auth, so check-sso never found a session and the console bounced through the auth endpoint forever. LibreChat and the portal oauth2-proxy dodged the same edge by disabling Secure app-side, but the account console needs SameSite=None (which requires Secure), so the only correct fix is to assert the true public scheme at the edge — which deploy/README.md already named as the proper fix and nobody had applied. Verified live: with X-Forwarded-Proto: https, KC's auth endpoint sets AUTH_SESSION_ID=...;Secure;SameSite=None; without it, SameSite=Lax and no Secure plus a "Non-secure context detected" WARN. Only the :8081 block needs it; the :8443 LAN backchannel terminates its own TLS and already reports https. enterpriseaiframework-4f1 Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/caddy/Caddyfile | 20 +++++- tests/test_caddy_keycloak_forwarded_proto.py | 71 ++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/test_caddy_keycloak_forwarded_proto.py 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_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}" + ) From c94bb6323d79725a58aea70ae58d4585671a43ce Mon Sep 17 00:00:00 2001 From: alice Date: Mon, 10 Aug 2026 18:08:16 +0000 Subject: [PATCH 2/5] Poll the Agents view while an agent is still starting, so a booted pod stops reading as stuck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agents list only refetched on a tab switch or an explicit action — never while the tab was being watched. So an agent you just created (or just wired a connector to) sat at "starting…" until you navigated away and back, even though its pod had gone Running seconds later. A working boot that reads as a stuck one is the finding-43 failure mode; a dogfood user hit exactly it. While the Agents tab is on screen and any agent is still `starting`, re-poll (4s) until nothing is transitioning; stop when the tab is left or the browser tab is backgrounded, and refetch once on return (a hidden tab throttles the timer to a stall). Verified live against the deployed control plane: the agents API already reports rudi as running with the pod Running — the gap was purely that the page never re-asked. enterpriseaiframework-7af Co-Authored-By: Claude Opus 4.8 (1M context) --- control-plane/app/portal_static/app.js | 44 +++++++++++++++ tests/test_agents_status_poll.py | 77 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/test_agents_status_poll.py 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/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" + ) From eb3ec42bb99072e695cf21ae339d9aaf98a8a529 Mon Sep 17 00:00:00 2001 From: alice Date: Mon, 10 Aug 2026 18:08:32 +0000 Subject: [PATCH 3/5] Authorise the injected console shim in opencode's CSP, so the agent console is not a broken shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode serves 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; the browser, finding no matching hash, silently refused to run it. With the shim dead, the compiled bundle resolved its server as `location.origin` with no path, so every runtime request — `/api/event`, `/config`, the pty socket — hit the portal origin ROOT unprefixed and 404'd. `/api/event` reconnected in a tight loop and the console was a broken shell (the "Open console" breakage a dogfood user reported). Fix: when the entry document carries a CSP, extend its `script-src` (or add one that mirrors `default-src`) with the shim's own sha256 — the same mechanism opencode admits its own inline script by. Nothing else inline is permitted; opencode's policy is preserved in full. Stripping the CSP was rejected: this surface proxies an unattended agent holding a spendable key. Verified end-to-end in Chromium against the real rudi daemon (app run on loopback, real Basic-auth hop): before, `window.fetch` unwrapped and `EventSource('/api/event').url` pointed at the origin root with a 404 flood; after, the shim runs, the URL is rewritten to `/agents/rudi/api/event`, the SSE stream OPENS (readyState 1), `/config` returns 200, and there are zero CSP refusals. Hermetic test asserts the authorised hash equals the hash of the exact injected script bytes, so shim text and CSP can never drift apart. enterpriseaiframework-f4c Co-Authored-By: Claude Opus 4.8 (1M context) --- control-plane/app/agent_console.py | 66 ++++++++++++++++- control-plane/tests/test_agent_console.py | 86 ++++++++++++++++++++++- 2 files changed, 147 insertions(+), 5 deletions(-) diff --git a/control-plane/app/agent_console.py b/control-plane/app/agent_console.py index d7a063d..f44c0cf 100644 --- a/control-plane/app/agent_console.py +++ b/control-plane/app/agent_console.py @@ -66,6 +66,7 @@ """ import base64 +import hashlib import re import httpx @@ -128,9 +129,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() + "})();" + ) + + +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 +314,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/tests/test_agent_console.py b/control-plane/tests/test_agent_console.py index 55501a8..265b3dc 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,71 @@ 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_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. From 0309e1cce6b369598002a2a51154b707caf5665a Mon Sep 17 00:00:00 2001 From: alice Date: Mon, 10 Aug 2026 18:31:41 +0000 Subject: [PATCH 4/5] kaniko-build: create the context ConfigMap instead of apply, so >256KB contexts build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `kubectl apply` records a full copy of the object in a last-applied-configuration annotation, and annotations cap at 256KiB — so any build context between ~256KB and the ~1MiB ConfigMap limit this script actually guards (700KB) failed at ConfigMap creation with "metadata.annotations: Too long". Hit building the 289KB control-plane image. The ConfigMap name is unique per build, so there is nothing to reconcile; plain `create` avoids the annotation entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --- deploy/bin/kaniko-build.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 From 4996e9c9fea5936518e869af9d70a75b51151b89 Mon Sep 17 00:00:00 2001 From: alice Date: Mon, 10 Aug 2026 18:54:14 +0000 Subject: [PATCH 5/5] Teach the console router its prefix, so the agent console is not a blank page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CSP fix let the shim run, but the console was STILL a mostly-blank page — a toolbar over an empty
. Root cause (isolated in a real browser): opencode's console is an SPA whose router reads location.pathname to choose a view, and — like its asset and server URLs — it assumes it is mounted at the origin ROOT. Under /agents// the initial path matches no route, so it renders an empty
. Proven by loading the SAME daemon directly: at / it renders the full home ("Projects / Add project / Settings / Help / Create a session to get started"); at /agents// the identical daemon renders an empty
. The shim now also strips the prefix from location.pathname before the bundle runs (so the router initialises at root), interposes on history.pushState/replaceState to keep every navigation prefixed in the address bar, and re-adds the prefix after load — so a reload lands back on the console and not on the chat surface at the origin root. The CSP hash covers these bytes automatically (single source in _shim_js). Verified in a real browser against the real daemon, through the app:
renders the home view on open AND after a reload, address bar stays at /agents//, zero CSP refusals, no JS errors. Known follow-ups (separate, not blocking): the CSS-loaded font /assets/Inter.ttf 404s at the origin root (cosmetic; shim rewrites JS-initiated requests only, not CSS url()), and browser back/forward is not yet prefix-aware. enterpriseaiframework-f4c Co-Authored-By: Claude Opus 4.8 (1M context) --- control-plane/app/agent_console.py | 35 +++++++++++++++++++++-- control-plane/tests/test_agent_console.py | 26 +++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/control-plane/app/agent_console.py b/control-plane/app/agent_console.py index f44c0cf..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 @@ -167,6 +176,28 @@ def _shim_js(name: str) -> str: "['EventSource','WebSocket'].forEach(function(n){var C=window[n];if(!C)return;" "window[n]=new Proxy(C,{construct:function(t,a){a[0]=fix(a[0]);" "return Reflect.construct(t,a);}});});" + # 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);});" "})();" ) diff --git a/control-plane/tests/test_agent_console.py b/control-plane/tests/test_agent_console.py index 265b3dc..1ac9f39 100644 --- a/control-plane/tests/test_agent_console.py +++ b/control-plane/tests/test_agent_console.py @@ -445,6 +445,32 @@ def test_the_injected_shim_is_authorised_by_the_forwarded_csp(world): ) +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