Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 96 additions & 5 deletions control-plane/app/agent_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,23 @@
location.origin`). Mounted under `/agents/<name>/` 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
`<main>` — 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
Expand All @@ -66,6 +75,7 @@
"""

import base64
import hashlib
import re

import httpx
Expand Down Expand Up @@ -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"<script>" + _shim_js(name).encode() + b"</script>")


def _shim_js(name: str) -> str:
"""The shim's JavaScript, WITHOUT the `<script>` wrapper.

Separated from `_shim` because opencode serves the console under a strict
Content-Security-Policy whose `script-src` has no `'unsafe-inline'` — it whitelists its
own one inline script by sha256 hash. An injected inline script the browser cannot
verify is simply not executed, silently (enterpriseaiframework-f4c), and with the shim
dead every runtime request resolves against the origin ROOT instead of this prefix, so
`/api/event` and the pty socket 404 in a reconnect loop and the console is a broken
shell. So the exact bytes hashed into the CSP below must be the exact bytes injected;
both come from here.
"""
prefix = _prefix(name)
return (
"<script>(function(){var P=" + _js_string(prefix) + ";"
"(function(){var P=" + _js_string(prefix) + ";"
"function mine(p){return p===P||p.indexOf(P+'/')===0;}"
"function fix(u){try{"
"if(u===null||u===undefined)return u;"
Expand All @@ -151,8 +176,66 @@ def _shim(name: str) -> bytes:
"['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);}});});"
"})();</script>"
).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 `<main>` — 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 `<main>` 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-<base64>'` 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:
Expand Down Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions control-plane/app/portal_static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -457,13 +468,44 @@ 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"); }
catch (e) {
$("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;
Expand Down Expand Up @@ -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) {
Expand Down
112 changes: 110 additions & 2 deletions control-plane/tests/test_agent_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -138,10 +152,12 @@
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()
Expand All @@ -161,7 +177,8 @@
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":
Expand Down Expand Up @@ -378,6 +395,97 @@
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 "<script>" + injected + "</script>" 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/<name>/`, the initial path matches no route and the app
renders an empty `<main>` — 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 <main> (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.

Expand Down
8 changes: 6 additions & 2 deletions deploy/bin/kaniko-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading