building vodou.

Chrome refuses an iframe silently, and onload still fires

A shipped panel proxied to an opt-in process, then went blank behind frame-ancestors. Two failure classes, one five-minute check you can run on your own stack.

Chad Priest / / 8 min read

Kill the optional background process your dev setup starts. Not restart it. Kill it, leave it dead, and then use your app the way someone who just installed it would.

I did that on 2026-08-26 as a verification step before flipping a feature flag off by default, and a shipped panel in my desktop app returned 502 on every request. The panel had worked every day for weeks. It worked because a process that was documented as opt-in happened to be running on my machine, and nothing in the codebase said the panel depended on it.

That was the first of two failures. The second one was worse, because it produced no error at all.

The twin was opt-in, and a shipped tray returned 502 without it

The surface is a memory map: a graph view over the agent’s memory store, with sources, conflicts, entities and full-text search down the side. It exists in two places. It is a tab inside the main console, served by the gateway on :8765, and it is a tray inside Vodou One, the desktop app on :8768.

One’s dev server and its production server both proxied /brain-api/* to :8767, which is a standalone brain console. That standalone is a twin: a second process serving the same views, useful when you want the graph without the rest of the console. It is opt-in. It is behind an environment flag. Nobody promised to run it, and the plan for that week was to stop starting it by default.

So the fix for the data half was one line in one/web/server.mjs, where BRAIN became GATEWAY, and one target change in one/web/vite.config.ts from :8767 to :8765. The path rewrite did not move, because the gateway already serves identical routes under /api/brain/*. With the twin dead, One’s /brain-api/overview went from 502 to 200 and returned 52,423 chunks.

That took ten minutes. The other half took the rest of the day.

Beforetray data → :8767 (opt-in twin)twin off = 502 on every routeframe → gateway, refused by CSPrefusal is silent: blank panelAftertray data → :8765 (gateway)twin off = 200, 52,423 chunksgateway names :8768 in frame-ancestorsorigin derived from the port variable

The panel went blank, with no console error and nothing in the network tab

The map itself is an iframe. The tray frames the gateway’s page rather than reimplementing a graph renderer twice.

When I pointed that frame at the gateway, it rendered nothing. Not a broken image, not an error page. A white rectangle. Chrome’s console was clean. The network tab showed the document request completing. The iframe’s onload handler fired, on time, exactly as it does on success.

The gateway answers with a Content-Security-Policy header carrying frame-ancestors 'self' chrome-extension://…, which is the right default: only the console itself and the browser extension may frame it. One is :8768, a different origin from :8765, so Chrome refused to render the document inside that frame. Refused, and told nobody. There is no event for it, there is no error on the element, and the refusal does not read as a refusal anywhere in DevTools by default.

I got a name for it by probing contentWindow from the parent, which is how I learned the detection trick that the rest of this post is really about.

An about:blank you are allowed to read is a frame that was refused

Here is the property that makes this detectable, and it is counterintuitive enough that I want to state it on its own line.

If you can read iframe.contentWindow.location.href from the parent, your cross-origin frame did not load.

A frame that successfully loaded a document from another origin is walled off: touching location.href throws a SecurityError. A frame that was refused by frame-ancestors is left holding about:blank, which inherits your origin, so the read succeeds and returns the string "about:blank". Success throws. Failure answers politely.

Once I had that, the decision was not a technical one. Either One stops framing and reimplements the view, or the gateway widens its list by exactly one origin. I widened it. MCP-servers/Vodou-Console/src/api/console-two.ts now names One’s loopback origin, and it derives that origin from the port variable the process actually binds, so the header and the process cannot drift apart. It names both 127.0.0.1 and localhost, because either spelling reaches the same app and only one of them would have worked, which is a bug that would have shown up as the same blank panel on somebody else’s machine.

console-two.test.ts asserts the header with exact equality rather than toContain. That is why the test had to change in this commit: the entire point of that assertion is that nothing joins the list by accident. I added a negative case so that a future wildcard fails loudly instead of quietly working.

old data path: 502 when offnew data path: 200framesrefused unless listedDesktop tray :8768Gateway :8765serves data and themap pageOpt-in twin :8767Map iframeThe data half failed loudly. The frame half failed with a blank rectangle and a clean console.
Two halves of one panel, two different failure modes

The property: every origin in a frame-ancestors list comes from the same value the server binds

Both failures are one class, stated two ways.

A surface’s dependency on a process is only real if something asserts it with that process stopped. An integration test that runs against your full dev environment will never find this, because your dev environment is precisely the set of processes that are all running.

And for the header specifically: every origin allowed to frame you must be computed from the same configuration value the allowed process binds to, including both hostname spellings that resolve to it. A hand-typed origin string in a CSP header is a duplicate of a number that lives somewhere else, and the day someone changes the port, the failure is a white rectangle.

Run this against your own embedded panel in five minutes

Nothing here is mine. Three checks, generic stack.

First, find out what your frontend proxies to, and compare it against what your process supervisor is contractually obliged to start:

grep -rnE '(proxy|target|upstream|rewrite).*(localhost|127\.0\.0\.1):[0-9]{4,5}' \
  --include='*.{js,mjs,ts,json,conf}' . | sed -E 's/.*:([0-9]{4,5}).*/&/'

Every port in that output is a dependency of a shipped surface. If one of them belongs to a process behind a flag, a dev script, or a nohup line in somebody’s notes, you have the 502.

Second, prove it. Stop the optional process and hit the routes:

kill "$(lsof -ti tcp:8767)"          # your optional process
for p in overview sources entities; do
  printf '%s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' \
    http://127.0.0.1:8768/brain-api/"$p")"
done

Passing looks like overview 200. Failing looks like overview 502, or 000 if the proxy target refuses the connection outright.

Third, the silent one. Paste this into the console of the page that holds the frame:

for (const f of document.querySelectorAll('iframe')) {
  let refused = false;
  try { refused = f.contentWindow.location.href === 'about:blank' && !!f.src; }
  catch { refused = false; }            // SecurityError means it really loaded
  console.log(f.src || '(no src)', refused ? 'REFUSED' : 'ok');
}

Any line printing REFUSED next to a real src is a frame the browser threw away. Then confirm the cause from the other side:

curl -sI http://127.0.0.1:8765/ | grep -i 'content-security-policy'

Read the frame-ancestors list and check that the origin of the page you just ran that snippet on appears in it, with the same hostname spelling the browser is using. localhost and 127.0.0.1 are different origins to a browser and identical to everything else you own.

What the agent-architecture writing leaves out: the surfaces that read the memory

There is a lot of good work on the inside of this problem. AgentBrain argues for an identity backbone and decomposed episodic units with provenance instead of flat cosine similarity, and it is right. The 2026 production-agent survey on DEV makes the case that the competitive gap is architecture rather than model quality: decomposition, orchestration, memory, aggregation, operational controls. Agent Surface goes furthest toward what I hit, treating docs, APIs, CLIs and MCP tools as one design problem rather than four.

None of them cover the read surfaces. Once your memory store is good, humans look at it, and they look at it through panels that are hosted by a different process than the one that owns the data. That is where the boring failures live: a proxy target that outlived its process contract, a security header that is correct and unhelpful, two spellings of the same host. Port numbers are not even distinctive. Uteke, another local-first memory engine, publishes a Docker one-liner that binds the same 8767 my twin was using. If two unrelated memory projects picked that number independently, a hardcoded port in your proxy config is not a name for anything.

Still live: the app that no longer depends on an unsupervised process is itself unsupervised

The honest limitation. I removed a shipped surface’s dependency on a process nobody promised to run, and the app serving that surface is currently an ad-hoc node process I started by hand on :8768. It dies unpredictably. There is a launchd installer written and sitting in the repo, uninstalled, because my own automation classifier will not let an agent install a LaunchAgent on my machine and I have not run it myself yet.

So the dependency did not disappear. It moved up one level, from a process I could stop testing against to a process I can only fix by typing. That is still an improvement, because the failure is now “the app is not running” instead of “the app is running and one panel inside it is a white rectangle.” Loud beats quiet. But I would rather not write the same post again in a month, so the installer is next.