# 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.

- Author: Chad Priest
- Published: 2026-09-11
- Canonical URL: https://blog.vodou.ai/frame-ancestors-silent-iframe-failure/
- Tags: architecture, observability, ai-agents, memory

---

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.

**Diagram (beforeafter)**

Before: the desktop tray proxied to an opt-in process on port 8767 and returned 502 when it was off. After: the tray proxies to the gateway on 8765 and the gateway names the tray's origin in its frame-ancestors list.

```text
  BEFORE: Before
    - tray data → :8767 (opt-in twin)
    - twin off = 502 on every route
    - frame → gateway, refused by CSP
    - refusal is silent: blank panel

  AFTER: After
    - tray data → :8765 (gateway)
    - twin off = 200, 52,423 chunks
    - gateway names :8768 in frame-ancestors
    - origin 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.

**Diagram: Two halves of one panel, two different failure modes**

The desktop tray sends data requests to the gateway and frames the gateway's map page. The frame edge is refused by the Content-Security-Policy frame-ancestors header, silently.

```text
  [Desktop tray :8768] --old data path: 502 when off--> [Opt-in twin :8767 (problem)]
  [Desktop tray :8768] --new data path: 200--> [Gateway :8765]
  [Desktop tray :8768] --frames--> [Map iframe (fixed)]
  [Map iframe (fixed)] --refused unless listed--> [Gateway :8765]

  notes:
    Gateway :8765: serves data and the map page

  The data half failed loudly. The frame half failed with a blank rectangle and a clean console.
```

## 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:

```bash
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:

```bash
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:

```js
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:

```bash
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](https://doi.org/10.5281/zenodo.20371135) 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](https://dev.to/monuminu/ai-agent-architecture-2026-building-production-grade-systems-patterns-benchmarks-and-lessons-5d34) makes the case that the competitive gap is architecture rather than model quality: decomposition, orchestration, memory, aggregation, operational controls. [Agent Surface](https://agentsurface.dev/) 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](https://github.laiyagushi.com/codecoradev/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.

---

Source: [Chrome refuses an iframe silently, and onload still fires](https://blog.vodou.ai/frame-ancestors-silent-iframe-failure/) by Chad Priest, from Building Vodou in Public.
