Skip to content

Make the sandbox frame actually run, and survive a reload - #17

Merged
jwaldrip merged 1 commit into
mainfrom
fix/sandbox-render-and-key-recall
Aug 11, 2026
Merged

Make the sandbox frame actually run, and survive a reload#17
jwaldrip merged 1 commit into
mainfrom
fix/sandbox-render-and-key-recall

Conversation

@jwaldrip

Copy link
Copy Markdown
Contributor

Two things, both surfacing as a blank frame on an HTML relic.

1. The sandbox script was never running

The frame is given sandbox="allow-scripts allow-popups allow-forms" with no
allow-same-origin, which is the point: its document lands in an opaque
origin
. Every request that document makes is therefore cross-origin with
Origin: null.

sandbox.html linked its handler as <script type="module" src="/assets/sandbox.js">.
Module scripts are fetched with CORS semantics. The asset carried no
Access-Control-Allow-Origin, so the browser refused it.

Verified against the deployed sandbox rather than inferred: with a listener
attached in the parent and the frame reloaded, relic:sandbox-ready never
arrives. The console is empty, because the failure lives in the frame's origin
and not the page's.

$ curl -sD- -H 'Origin: null' .../assets/sandbox.js | grep -i access-control
(nothing)

Fix: inline the bundle into the page at build time. Relaxing CORS on the
asset would fix the symptom by making the sandbox origin serve something
cross-origin, which is precisely the property the sandbox exists to remove. An
inline script fetches nothing, so there is no request to block and no header
anybody can regress later. The sandbox page's CSP has no script-src, so
inline runs.

Guarded at both ends: the build fails if the expected tag is missing (which
would silently ship the linked version), and the deploy asserts the served page
contains the handler and links no script.

2. A reload lost the key

Reading the fragment strips it from the address bar. That is worth keeping, but
it meant a refresh landed on a dead page telling the reader to go find the
original link.

The key is now remembered in local storage on the service origin, keyed by
relic, until the relic expires. It is written only after a successful mint, so
a key for a relic that does not exist is never stored; dropped when the relic
turns out to be expired, removed, or never published; and dropped when a
remembered key fails to parse, so corrupt storage cannot wedge the page.

Storage that is absent or throws — private browsing, quota, embedded webview,
blocked site data — degrades to doing nothing, which is exactly the behaviour
that existed before. Reads sweep every expired entry, not just the one asked
for, so storage does not accumulate keys to relics that stopped existing.

This changes who can open a relic. For as long as an entry lives, anyone
using that browser profile can reopen it without ever having been sent the
link. /policy now states that, including that clearing site data removes
them.

Verification

  • 456 tests pass, up from 439
  • 12 on the vault: expiry, sweep-on-read, corrupt entries, leaving other keys
    alone, and both degradation paths
  • 5 on the reload flow end to end, including that a dead relic evicts the
    remembered key and that a corrupt one is dropped rather than retried
  • built sandbox.html confirmed to contain the handler inline and no src=
  • biome, tsc clean

Two things, both about HTML relics rendering blank.

The sandbox frame is given no allow-same-origin, which puts its document in an
opaque origin. Every request that document makes is therefore cross-origin with
Origin: null, and sandbox.html linked its handler as a type="module" script.
Module scripts are fetched with CORS semantics, the asset carried no
Access-Control-Allow-Origin, so the browser refused it. The script never ran,
the frame never announced itself, and the parent posted markup to a listener
that did not exist.

It failed with an empty console, because the failure was in the frame's origin
rather than the page's. Verified against the deployed sandbox: the frame never
sends relic:sandbox-ready.

The bundle is now inlined into the page at build time. Relaxing CORS on the
asset would have fixed the symptom by making the sandbox origin serve something
cross-origin, which is the property the sandbox exists to remove. An inline
script fetches nothing, so there is no request to block and no header anybody
can regress. The build refuses to produce a linked version, and the deploy
asserts the served page carries its handler and links no script.

Second, a reload lost the key. Reading the fragment strips it from the address
bar, which is worth keeping, but it meant a refresh landed on a dead page
telling the reader to go find the original link. The key is now remembered in
local storage on the service origin, keyed by relic, until the relic expires,
and dropped as soon as the relic is gone or the key stops working. Storage that
is absent or refuses to write degrades to the old behaviour rather than costing
somebody the relic they are looking at.

That changes who can open a relic: anyone using that browser profile can reopen
it without ever being sent the link. The disclosure says so rather than leaving
it to be discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAZyikoYbH8XdXkGJrxFCv
@jwaldrip
jwaldrip merged commit 2fb4ae9 into main Aug 11, 2026
1 check passed
@jwaldrip
jwaldrip deleted the fix/sandbox-render-and-key-recall branch August 11, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant