598 Commits
Author SHA1 Message Date
velzie 1f2946c632 [core] rewriter: add dump command to native cli 2026-10-14 16:33:37 -04:00
velzieandClaude Opus 5.5 2e55e7d05a several fixes related to hash urls (#139)
* [runway] RUNWAY_PORT_BASE moves the harness ports

The scramjet harness, its wisp server and the bare harness were fixed at
4500-4502, so two runway instances (or anything else on those ports)
could not run side by side. RUNWAY_PORT_BASE moves all three, and the
harness page finds wisp next to its own port.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb

* [core] URL fragments: keep them verbatim and keep fragment navigations in the document

The fragment went through the URL codec, so the real URL's fragment was
not the site's: `#a/b` became `#a%2Fb`, which broke `:target`, native
scroll-to-anchor, text fragments and `location.hash` round trips. It is
never sent anywhere, so it now stays as written. The JS rewriter
encoded it into static import URLs too, so `import "./m.js#a"` and
`import("./m.js#a")` could load two instances.

In-page `#x` navigations reloaded the document: rewritten URLs carry
the navigator's query params (`$io`, ...) and the document's real URL
carries its loader's, so the browser never saw the same document. A
navigation to the document's own URL (links, location setters, assign,
replace, `window.open(url, "_self")`, meta refresh, controller.go) now
goes to its real URL with the new fragment, and the browser applies its
own fragment-navigation and replace rules. Fragment-only hyperlinks are
left relative, so they resolve when followed - after pushState too.

That relies on the real <base href> being a proxy URL, which it was
not: anything left relative resolved against the real site. It is now
rewritten (ignored `data:`/`javascript:` bases are left for the browser
to ignore). The client's base URL resolved a relative base against the
origin rather than the document URL; only the first base element with
an href counts; about:blank and srcdoc documents fall back to their
creator's base URL.

History: pushState/replaceState implement "can have its URL
rewritten" rather than comparing origins, resolve against the base URL,
treat "" like null as Chrome does, and keep the document's own request
params on the real entry URL, so a frame reloads as a frame.

Also: CSS `url(#id)` is left alone (gradients, clip paths, masks,
filters), blob URLs keep their fragment (`#t=5`, `#page=2`), window
handler content attributes (onhashchange, onpopstate, onmessage, ...)
are rewritten instead of running against the real globals, and
`_top`/`_parent` target keywords are matched case-insensitively.

Tests: runway fragments.ts (end to end against bare Chrome, reached
cross-origin the way real pages are) and rewriter-fragments.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb

* [core] fragment review fixes: escaping, base elements, self-navigation, file: history

- A fragment put back into a static import's string literal is escaped
  for one. The fragment percent-encode set leaves `\` and `'`, so
  `import "./m.js#a\\"` came out as invalid JavaScript. The module's own
  URL had the same problem in `$scramjet$meta(import.meta, "...")` and
  `$scramjet$import("...", ...)`: the service worker sees the fragment a
  module was requested with, and the rewriter put it in unescaped. Both
  now go through one escape function, and Flags carries the escaped base.

- Only an HTML base element in the document's own tree sets the base URL.
  The service worker adopted the first `<base>` it saw, including one in
  a <template> or an SVG/MathML element named base, so later scripts
  loaded from the wrong directory. The client matched `base[href]`, which
  also finds the SVG one; it now checks the namespace.

- A navigation to the document's own URL only reuses its real URL when it
  has a fragment - when it stays in the document. Without one it loads a
  new document, and reusing the real URL kept the initiator of whoever
  first loaded it (`$io`), so `location.assign(location.href)` after
  entering from another origin was sent as Sec-Fetch-Site: same-site. The
  cost is that such a navigation pushes a history entry where the browser
  replaces one (fragment-self-navigation-replaces-entry, listed failing).
  A <base> naming the document still resolves to its real URL, since it
  loads nothing itself.

- "can have its URL rewritten" lets a file: URL change its query, as HTML
  says; it fell through to the fragment-only rule. Moved out of the
  history interceptor so it can be tested on its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb

* [core] the HTML parser records each element's namespace

Whether a <base> sets the document's base URL depends on it being an
HTML element, and the rewriter guessed that from ancestor names. The
parser's own foreign-content tracking made the same guess: `title`,
`mi` and `annotation-xml` counted as integration points in any
namespace, `annotation-xml`'s `encoding` was never read, and breakout
start tags (`<p>`, `<div>`, `<font color>` ... inside <svg>) did not
end foreign content. So `<math><annotation-xml><base>` set the base
URL, and so did a base in an HTML-looking spot that Chrome puts in SVG;
an SVG element named `template` was taken for an inert HTML one.

The parser now decides each element's namespace by the tree
construction rules - MathML text and HTML integration points,
`annotation-xml` by its encoding, breakout tags including `font` with
color/face/size - and DomBuilder records it on the Element. The
tokenizer's raw-text, CDATA and self-closing decisions read the same
state, and only an HTML element is void (an SVG `base` has children).
The base check is now `namespace === "html"` outside an HTML template.

Parser tests pin namespaces to the trees Chrome builds for the same
markup; runway covers the base cases both ways.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MJ6gCzt6WZNrhpy8GvnNb

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-03 15:45:46 -04:00
velzie 79d493f36b move siteFlags to controller and make them weaker 2026-09-25 13:54:01 -04:00
velzie b6b64a1af9 [core] replace siteFlags regex with dumb hostname matcher 2026-09-25 13:06:31 -04:00
velzie 482f92c240 fix layered interceptors 2026-09-25 13:03:08 -04:00
velzie bd27269b18 review pass tests 2026-09-25 11:32:36 -04:00
velzie 5698054656 [core] do not cache topurl, add bust to flagcache 2026-09-25 10:16:02 -04:00
velzie a482b5ef2c replace prettier with oxcfmt 2026-09-25 10:05:04 -04:00
velzie 5ee0da921e [core] fix exploit involving deleting globals from a parent frame to explode a reused client bundle's interceptors 2026-09-25 09:47:43 -04:00
velzie 9ea389384d [controller] fix throw when injecting into already loaded client 2026-09-25 09:12:43 -04:00
velzie ac33173067 CV build system (#129) 2026-09-25 08:51:43 -04:00
Toshit ChawdaandClaude Opus 5.5 47a817e6e2 [core] hand out client ids from the box, not Math.random (#131)
A client's id is how a posted message names its sender: the recipient looks
it up in the box's clientIds to answer MessageEvent.source. That only works
if no two clients in the box share one, and a random draw cannot promise it.
Math.random and crypto.getRandomValues keep their state per realm, and an
engine can be made to start every realm from the same seed (V8's
--random-seed); then every realm that installs its own client draws the same
first value, clientIds keeps whichever registered last, and every message
reads as sent by that one.

Turnstile hit exactly this: its widget, a later srcdoc frame and the page
all drew one id, so the widget's messages came back sourced from the srcdoc
frame, failed the widget's e.source === iframe.contentWindow check, and the
challenge's completion was never accepted.

The box is the only place an id is resolved, so the box numbers them. That
is unique by construction, and takes nothing out of the page's own random
sequence.

The test pins Math.random per realm before any script runs and checks that
two frames and the page are each named as the sender of their own message.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 21:18:13 -04:00
velzieandClaude Opus 5.5 c5d59ce573 [core] update all of core to use safe iteration
Brings the safe-iteration work over from the now-outdated
feat/safe-iteration (9d627e36, c0332df0) and applies it to everything that
has landed since. That branch's other commit, 91264d0a, is already on
develop through the stack.

`for...of`, spread and array destructuring all run the iteration protocol,
whose lookups land on `Array.prototype[@@iterator]` and
`%IteratorPrototype%` - both writable by the page. Any of them in the page
realm lets a page change what scramjet believes it read, or what it
passes on after checking: `el.prepend(...args)` spread `args` into the
native after `text.around` had inspected them, so a replaced array
iterator could hand the native a different node.

- `drain(xs)` in the snapshot: a for...of-able view of an array built
  from own properties, so none of the protocol's lookups reach the page.
- `no-unsafe-iteration` lint rule, at "error" for src/client and
  src/Tap.ts, with tests. Its messages now point at `drain` and
  `Reflect_apply`.
- All 45 sites it found: spreads into natives become
  `Reflect_apply(super.m, this, args)` (node.ts, reflect.ts, css.ts,
  settimeout, importScripts); loops become `drain`; the NamedNodeMap
  fallback indexes the native rather than running its iterator; Headers
  are copied with the native's own `forEach`. The brand-check plugin
  learns that `Reflect_apply(super.m, this, ...)` calls the native.
  `observedAttributes` keeps `for...of`: it is the page's own iterable,
  and the browser runs its protocol too.
- Things the rule cannot see: `Promise_all` still walked its argument
  with the protocol and called each element's `then`; it is now a
  snapshot helper over `Promise_then`, and the unused `Promise_race`,
  `Promise_allSettled` and `Promise_any` are gone. Tap.dispatch, which
  runs in the page on every navigation, drops spread, `.map`, `.find`,
  `.includes` and `.push`. `Array.prototype` method calls in
  client.ts, chrome.ts, event.ts, err.ts and webidl.ts go through the
  snapshot, and `client.native`'s getters and setters, and
  sourcemaps' toString, stop going through `Function.prototype.call`.
- Cold indexed loops that only read `xs[i]` read as
  `for (const x of drain(xs))` now. Hot paths stay indexed: the text
  layer, per-write attribute rules, instanceof, per-call IDL
  conversion, stack formatting and performance entry lists. So do live
  platform collections, which `drain` cannot be handed.
- The commented-out `window.postMessage` proxy from the wrapPostMessage
  era is deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01AWUAE5wuAo6WKM7y5TncPC
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5.5 f7268b8c99 [core] final IDL cleanup
Supersedes #115, redone against develop now that the stack has landed.

From #115:
- `scramjet-core/intercept-brand-check` goes "warn" -> "error".
- Dead bindings: `Function_call` in client.ts, plus unused imports in
  entry.ts, err.ts, sourcemaps.ts and shared/index.ts.
- The type-generation rspack target gets `performance: { hints: false }`
  like the other four.

Getting brand-check to zero took a plugin fix, not suppressions. It
missed two ways of reaching a native: lib.dom declares `style` (and every
other attribute whose setter takes a different type) as a `get`/`set`
pair, which `nativeMember` did not recognise; and `super.x = v` runs the
native setter on `this`, which the plugin treated as never checking.
It now also reads lib.webworker for SharedWorkerGlobalScope. That clears
11 false positives. The rest:
- `Document.execCommand` really could return without touching the
  receiver. It now reads the receiver's own selection via
  `super.getSelection()` rather than the window's, which is also the
  correct document for a cross-frame call.
- `CSSMarginRule.style`, `CSSPositionTryRule.style` and
  `Element.setHTML` do reach `super` on every path, but lib.dom does not
  have them yet, so they are disabled with that reason.

Bugs found along the way:
- Worker `postMessage` called `super.postMessage` inside a GlobalScope
  static. As `GlobalScope`'s own doc says, `super` there only sees the
  global's own properties, and a worker keeps its members on
  `WorkerGlobalScope.prototype`, so it was undefined. It now goes
  through `client.native.window(this)`, as the window variant does too.
- The postMessage IDL `(sequence<object> or optional
  StructuredSerializeOptions = {})` is not valid IDL and failed the type
  level parser; it is now `optional (sequence<object> or
  StructuredSerializeOptions) = {}`.

tsc: 11 errors -> 0. Besides postmessage: `moveBefore` moves to the
ParentNode mixin where it belongs, `ScramjetRequestMode` (never existed)
becomes lib.dom's `RequestMode`, wrap.ts gets honest Window types, and
data: URL blobs get a `Uint8Array<ArrayBuffer>`.

eslint: 53 errors / 30 warnings -> 0 / 5. The no-globals errors now go
through snapshot wrappers (`_URL`, `_Uint8Array`, `_Map`, ...), with
`_Blob`, `WebAssembly_Module` and `selfLocation` added. sourcemaps.ts
reads its u32s off the bytes rather than a page-replaceable DataView.
entry.ts's load-time environment probes are exempt, with the reason
given. `quotes` gets `avoidEscape` so it stops fighting prettier.

Tool tests: the intercept fixture's fake box was missing
`objectPrototypes`, which `relevantPromise` now reads, and the "refuses
declarations without a checkReceiver" test is flipped to match
e438c4db, which removed that refusal on purpose.

Claude-Session: https://claude.ai/code/session_01AWUAE5wuAo6WKM7y5TncPC

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5.5 45340828ac [core] incumbent settings object: stamping, and remove wrapPostMessage (#113)
* [core] remove wrapPostMessage and add the incumbency test matrix

Carved out of the IDL interceptor branch: the postMessage-adjacent
changes, kept together because the rewriter half and the client half
have to move at the same time.

- The `$scramjet$wrappostmessage` rewriter function is gone, along with
  the `wrappostmessagefn` config key and the rust that emitted it
  (`visitor.rs`, `rewrite.rs`, `changes.rs`, `cfg.rs`). Wrapping every
  `postMessage` callee at rewrite time was both a large hit on rewritten
  output and unnecessary now that the client intercepts the member.
- `runway/src/incumbence.ts` is new: a matrix harness for incumbent
  settings object behaviour, plus the `incumbent-*` tests that drive it.
- `adversarial/postmessage-targetorigin.ts` covers the targetOrigin
  check, which is the one postMessage argument that is an origin
  comparison.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* trace the incumbent settings object

Resolves a collision with the element rewrite that neither branch could
see on its own: both lineages independently grew a `base64ToBytes` and a
`base64ToBytesFallback` in `shared/util.ts`. Merged naively the bodies
do not conflict - only the import list does - so the file ends up with
two definitions of each and does not compile.

Kept the implementations from this branch. The fallback here fills a
`new _Uint8Array(len)` by index, where the element one used
`_Uint8Array.from(binString, ...)`, and `from` over a string runs the
page-replaceable iteration protocol - the thing the no-unsafe-iteration
rule exists to catch.

Kept `base64Decode` from the element side, which is new there and has no
counterpart here; it reads through the surviving `base64ToBytes`.
`Array_from` and `String_fromCodePoint` leave the imports with the code
that used them.

* fully working pst based incumbent settings object

* stamp/lazystamp part 1

* stamp js side

* [runway] allowlist the incumbency matrix's known failures

`runway` exits 1 on any failure that is not in `failing_tests.json`
(`unexpectedFailing`), and only *reports* allowlisted tests that have
started passing (`noLongerFailing`). So the two directions are not
symmetric: a PR that fixes tests can leave the file alone and stay
green, but a PR that adds known-failing tests cannot.

The previous commit adds the incumbency matrix, 44 of whose tests fail
today - that is the point of the matrix, it is the gap being measured.
Without these entries this branch reports 52 unexpected failures and
exits 1.

Also drops the 13 entries the series fixes, which is correct at this
point in the stack: they are the cachestorage and interceptor tests the
per-API rewrites repair, and everything that repairs them is already
merged below this commit.

This is the one piece of `failing_tests.json` that could not wait for
the cleanup PR. The seven per-API PRs still leave the file untouched,
because each of them only moves tests from failing to passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* remove a comment

* kill nonce again???

* incumbency and postmessage fixes 1

* add backup incumbency through bind proxy

* [core] read the incumbency mode for the top-level frame

The incumbency mode is a flag like any other, and a subframe has to run
with its top-level frame's: the client reads it for client.topUrl and
the rewriter for flagsUrl(meta), so the two can no longer pick
different modes for the same script.

Replaces the script-URL override test, which asserted the old
behaviour, with one checking that a subframe inherits the top-level
frame's mode over the one its own URL matches.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NY4qYbtVkrhABqAUszUNt6

* incumbency fixes

* incumbency fixes?

* whatever

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5.5 34e8d51f80 [core] read site flags for the top-level frame, and inherit them in subframes
Flags were matched against whichever URL happened to be at hand - a
subframe's own, a script's, a worker's - so one page could run with
several sets of flags at once, and the service worker's rewriter could
disagree with the client about them.

URLMeta now carries topUrl, the top-level frame's URL, and every flag
is read for it. The client finds it by walking scramjet-controlled
parents (parentFrame/topmostClient, which topFrameName and
parentFrameName now share); the service worker from a new $top
parameter where it cannot tell, otherwise from the requesting client.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01NY4qYbtVkrhABqAUszUNt6
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5.5 93495e9477 [core] rewrite the element and attribute layer (#112)
* [core] intercept setAttributeNode so detached Attr writes are rewritten

Carved out of the IDL interceptor branch. `Element.prototype.
setAttributeNode` was intercepted with an empty body, so

    const attr = document.createAttribute("src");
    attr.value = "https://origin.example/x.js";
    script.setAttributeNode(attr);

reached no rewriter at all: `dom/attr.ts` only routes `Attr.value` back
through `setAttribute` for an Attr that already has an `ownerElement`,
and a freshly created one does not. Whether the resulting load stayed
inside the proxy came down to whether the service worker happened to be
controlling the document.

Rewritten in place on the node rather than replayed through
`setAttribute`, so that `el.getAttributeNode(name) === attr` and
`attr.ownerElement` both keep holding.

Covered by `adversarial/attribute-node-bypass.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* rewrite element 2

Full rewrite of the element/attribute layer onto the IDL interceptors.

Scramjet rewrites content attributes: `src` on an image is a proxy URL
in the live DOM, `nonce` is gone entirely, `style` has been through the
CSS rewriter. None of that may be visible to the page, so every
rewritten attribute is now *mirrored* - the value the page wrote is kept
alongside it in `scramjet-attr-<name>`, and every read answers out of
the mirror.

The rules are the same ones the HTML rewriter applies while parsing, so
an attribute written by script ends up in exactly the state it would
have been in had it come down in the markup. Everything that touches an
attribute goes through one `attributeAccess` path: this module's
`Element` members, `dom/attr.ts` for `Attr` and `NamedNodeMap`,
`dom/reflect.ts` for the reflected IDL attributes. Nothing reads
`scramjet-attr-` by hand any more.

New files: `dom/attr.ts`, `dom/node.ts`, `dom/reflect.ts`,
`dom/markup.ts`, `dom/frames.ts`. `CharacterData` moves to `dom/node.ts`,
keeping the arity declarations - without them `node.appendData()`
rewrote and appended the string "undefined" where the native owes the
page a TypeError.

Supersedes the `setAttributeNode` interception from the previous commit
with one that also covers `setAttributeNodeNS`.

Drops the stale `SetHTMLUnsafeOptions: object` placeholder from
`webidl.ts`: this branch models the dictionary properly in
`global.d.ts`, so keeping both declared it twice.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* [core] fix the element layer's regressions and remaining escapes

Review fixes for the element/attribute rewrite, each covered by
runway/src/tests/adversarial/element-layer.ts (24 of its 28 tests fail
without this commit).

Regressions against develop:
- script.textContent / innerText ran unrewritten: Blink defines both again
  on HTMLScriptElement.prototype (Trusted Types sinks), shadowing the Node
  and HTMLElement interceptors. Intercepted there too.
- A relative <base href> broke every URL getter: the native base still
  started with the prefix and unrewrote to a relative string. Resolved from
  the base element and the fallback base URL instead.
- setAttributeNode with an Attr the element already owns rewrote the
  rewritten value into the mirror. An owned Attr now goes straight to the
  native (no-op or InUseAttributeError).
- Appending to a parsed script rewrote it twice and duplicated the text:
  existing Text children are now recorded before any mutation.
- removeAttributeNS / removeNamedItemNS left a stripped attribute's mirror.

Escapes:
- setHTMLUnsafe, setHTML (style), insertAdjacentHTML and outerHTML into a
  script or style, Range.insertNode, Range.surroundContents and moveBefore
  all landed raw text in a script.
- SVG script href (and xlink:href under any prefix) was never rewritten.
- box.instanceof honoured a page-defined Symbol.hasInstance, and decided
  both text blanking and the markup rewriter's foreign context. Now
  OrdinaryHasInstance, with native brand/namespace checks at those sites.
- meta refresh content set by script was never rewritten; it is now a rule
  in htmlRules, re-run when http-equiv changes.

Correctness:
- Text blanked for an insertion is restored if the native throws.
- Text inserted into a connected, not-yet-started script now runs: Blink
  does not prepare a script on a data write, so the script is re-prepared
  with a native insert/remove once the rewritten program is in place.
- normalize() on an ancestor keeps its scripts' text; the source is
  recorded before the write that can run the script.
- nonce is the [[CryptographicNonce]] slot, including on MathML.
- The style mirror follows CSSOM and typed OM writes; iframe.sandbox is a
  real DOMTokenList over a detached stand-in; NamedNodeMap methods keep
  their identity; insertAdjacentText throws SyntaxError on a bad position.
- textContent no longer walks subtrees with no script or style in them.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fp1ZD3JFmSXkxjonJv6BMV

* [core] reach natives through client.native in the element layer

The element layer captured descriptors out of `client.nativeStore` and
called them with `Reflect_apply`, in all six of its modules (attr,
element, fragments, markup, node, reflect) - the only DOM code in the
tree that does. Everything goes through `new client.native.X(obj)` now,
the same as the rest of client/, and the comment in element.ts arguing
for the snapshotted descriptors is gone.

Also stops `getSVGDocument` answering through `this.contentDocument`,
which the page can shadow, in favour of the getter's own steps.

No behaviour change: runway (no WPT) is identical before and after.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fp1ZD3JFmSXkxjonJv6BMV

* [core] make the attribute and text layers classes owned by the client

Both layers were module-level `WeakMap`s keyed on the client, filled
lazily by `attributeAccess(client)` / `textAccess(client)` with a record
of closures over that client - global state with a lifetime nothing
tied to the client, reachable by anything that imported the module.

They are now `AttributeLayer` (client/attributes.ts) and `TextLayer`
(client/text.ts), constructed in the `ScramjetClient` constructor as
`client.attributes` and `client.text`. They live outside client/dom/
because every module there is loaded as an interceptor.

The pure name helpers (`isInternalAttribute`, `mirrorAttributeName`, ...)
move with the attribute layer and stay free functions, holding no state.
`insertAttributeNode` becomes `AttributeLayer.insertNode`, and attr.ts's
`attrValue` / `setAttrValue` become `visibleValue` / `setVisibleValue`,
so nothing reaches into another interceptor module for shared logic.

No behaviour change: runway (no WPT) matches the previous run, bar the
live-site site-youtube-search test, which passes on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fp1ZD3JFmSXkxjonJv6BMV

* elementlayer tests

* fix failing html tests

* vendor htmlparser2

* another review round

* more review + insert proxy finally

* more review 2

* some bullshit

* naiive attribute selector rewriter

* small perf improvements

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 79227af8c6 [core] rewrite WebSocket and add WebSocketStream onto client.Intercept (#106)
* [core] rewrite WebSocket and add WebSocketStream onto client.Intercept

Moves the socket APIs off the hand-rolled traps and onto the IDL
interceptors from the base PR.

- `requests/websocket.ts` -> `requests/WebSocket.ts`, rewritten as an
  intercepted class.
- `requests/WebSocketStream.ts` is new: the API had no interceptor at
  all, so a page could reach the real origin through it.
- Restores `socketmap`/`socketstreammap` on the singleton box. These
  are typed on `FakeWebSocketState`/`FakeWebSocketStreamState`, which
  arrive with this PR, so the base could not carry them.

A fake socket is an `EventTarget`, so its events go out through
`dispatchEvent` and carry `isTrusted: false`. The box's `trustedEvents`
set (base PR) is what makes them read as trusted; `adversarial/
websocket-fidelity.ts` covers that along with the rest of the surface.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* websocket proper dispatch

* fix?

* small prototype fix

* small fuxes

* more tests

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 441927ab97 [core] rewrite the event interceptor onto client.Intercept (#103)
* [core] rewrite the event interceptor onto client.Intercept

Moves `shared/event.ts` off the hand-rolled traps and onto the IDL
interceptors from the base PR. Behaviour changes are in how events are
identified and how synthetic ones are marked, not in the set of events
handled.

- `addEventListener`/`removeEventListener` take their options through
  `readAddEventListenerOptions`/`readEventListenerOptions` rather than
  reading properties off the argument directly, so a page cannot
  observe or intercept the read.
- Listener bookkeeping moves from `client.eventcallbacks` to the
  singleton box's `eventhandlers`, keyed per realm.
- `isTrusted` for events scramjet synthesizes is answered from the
  box's `trustedEvents` set rather than by defining the property on the
  instance. `isTrusted` is a prototype accessor, so an own property
  there is itself the tell.
- `MessageEvent.origin` now reads the `$scramjet$messagetype` stamp
  that `postmessage.ts` already writes, instead of inferring the shape
  from whether an origin happens to be present. Messages that are not
  ours - service worker and embedder control messages - answer with the
  site's origin rather than the proxy's.
- `alwaysLastBubble` (utils and inject) takes `stopPropagation` and
  `stopImmediatePropagation` from `nativeStore` once at setup instead
  of constructing a native wrapper per event.

`adversarial/events-fidelity.ts` covers the above against the bare
harness as oracle.

Verified against feat/idl-groundwork: rspack builds all bundles; tsc,
the types build and eslint are all unchanged from the base (26 errors /
11 files, 7, and 283 problems respectively). All are pre-existing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* fixes

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 4e8b1139b0 [core] rewrite the CSS and document interceptors onto client.Intercept (#109)
Moves the CSS, document and related DOM surface off the hand-rolled
traps and onto the IDL interceptors from the base PR.

- `dom/css.ts` is rewritten: URL rewriting now goes through the
  CSSOM interfaces rather than restringifying rules, so a page reading
  back `cssText` sees what it wrote.
- `dom/Navigator.ts` and `dom/Audio.ts` are new interceptors for
  surfaces that previously had none.
- `dom/document.ts`, `dom/fragments.ts` (Range), `dom/fontface.ts` and
  `shared/blob.ts` move onto Intercept with IDL-typed members.

Deliberately excludes `dom/element.ts` and `dom/attr.ts`: those belong
to the element rewrite and land there.


Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 f37460f9ca [core] rewrite fetch, XHR, EventSource and headers onto client.Intercept (#105)
* [core] rewrite fetch, XHR, EventSource and headers onto client.Intercept

Moves the request APIs off the hand-rolled traps and onto the IDL
interceptors from the base PR.

- `requests/xmlhttprequest.ts` -> `requests/XMLHttpRequest.ts` and
  `requests/eventsource.ts` -> `requests/EventSource.ts`, both rewritten
  as intercepted classes rather than trap tables.
- `shared/headers.ts` grows the carried-header vocabulary:
  `CARRIED_HEADER_PREFIX`, `carriedHeaderName`, `isCarriedHeaderName`
  and `uncarriedHeaderName`. Headers scramjet has to move across the
  proxy boundary are now named by one rule rather than ad hoc per site.
- `dom/beacon.ts` is gone; `sendBeacon` is handled with the rest of the
  request surface.

Tests: `request-response-fidelity`, `headers-view` and `xhr-fidelity`,
all differential against the bare harness.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* fixes

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 4c26b283a6 [core] rewrite the storage and cache interceptors onto client.Intercept (#107)
* [core] rewrite the storage and cache interceptors onto client.Intercept

Moves CacheStorage, IndexedDB and Web Storage off the hand-rolled traps
and onto the IDL interceptors from the base PR. All three are origin
scoping fixes to interceptors that already existed.

- `shared/caches.ts` keys cache entries by the site's URL rather than
  the rewritten one, so two origins proxied through scramjet no longer
  share a cache entry.
- `dom/storage.ts` scopes `localStorage`/`sessionStorage` per site
  origin instead of per proxy origin.
- `shared/indexeddb.ts` moves onto Intercept with IDL-typed members.

Tests: `cache-keying`, `cache-storage` and `storage-scope-leaks`, the
last covering the cross-origin reads the old keying allowed.

The Cookie Store API was split out of this PR: it is a new interceptor
for a surface that had none, not a scoping fix, and it shares nothing
with these three.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* fixes

* [runway] update failing_tests for the storage and cache fixes

Eight entries pass now and come out: `storage-clear`,
`storage-delete-property`, `storage-key-by-index` and
`storage-missing-property-is-undefined` from the Web Storage rewrite,
`cachestorage-add-and-addall`, `cachestorage-keys-not-namespaced` and
`cachestorage-request-keys-urls` from the cache one, and
`platform-indexeddb-databases` from scoping `databases()`.

`cachekey-retrieved-response-is-fully-corrected` goes in. The test is
cross-PR: this branch ships the `tag()` that marks a cache entry as
having come off the network, and `shared/requests/fetch.ts` on
feat/idl-requests is what reads `box.taggedResponses` to unrewrite the
URL and put the stripped headers back. It passes once both land, and
the entry comes out then.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018Zn7Pst62DZXqzGyrkQ68o

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 befa62f39d [core] stop Intercept refusing declarations without a checkReceiver
`Intercept` threw for any declaration that converts IDL arguments and
does not pass a `checkReceiver`. No call site has ever passed one - 19
declarations on develop, zero with a receiver check - so the refusal
fired for every such interface, and `loadModules` swallowed the throw
into a `dbg.error`. Because it aborts the module function, everything
declared after the offending member went with it.

10 of the 19 declarations on develop were being refused: CookieStore,
Document.cookie, History, all five of performance.ts, StorageManager
and FileSystemHandle behind it, and Worklet. The runway failures are a
readout of exactly that set - ckjar-*, cookies-*, the pushstate tests,
platform-performance-observer, platform-resource-timing-own-entries -
none of which fail on 060552a4, before #110 landed.

The ordering it guarded is real: Web IDL brand-checks the receiver
before converting arguments, and conversion runs page code. But the
predicate asked whether a member declares IDL arguments at all, not
whether its conversion can run any page code, so `@Arguments()` with no
types was refused too. And a promise-only interface like Cache or
CookieStore has no member that can satisfy the contract at all, since
they reject rather than throw. Refusing to install is much the worse
outcome: for the storage-shaped modules it means no origin scoping,
which is a cross-origin leak traded for a fingerprint.

#117 tracks the real fix - a predicate that asks whether a conversion
can run page code, an async brand check, and a build-time failure.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018Zn7Pst62DZXqzGyrkQ68o
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 c1792a7789 [core] rewrite the navigation, timer and performance interceptors (#110)
Moves the last of the per-API surface off the hand-rolled traps and
onto the IDL interceptors from the base PR.

- `dom/open.ts` implements `window.open` through the base PR's
  `openWindowSteps`, and answers `frameElement` about the window the
  page asked about rather than the proxy's.
- `dom/history.ts` keeps `pushState`/`replaceState` URLs in the site's
  origin.
- `dom/performance.ts` unrewrites the URLs in performance entries, so
  timing data stops naming the proxy.
- `shared/settimeout.ts` moves onto Intercept with IDL-typed members.

Tests: `frame-element` (the one Window attribute whose whole job is an
origin comparison - under scramjet every proxied document genuinely is
same-origin, so that step has to be re-made against the origins the
sites themselves have) and `origin-scoping`.


Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-24 16:45:15 -04:00
velzie b287d785e0 [core] rewrite the worker, OPFS and BroadcastChannel interceptors (#108)
* [core] rewrite the worker, OPFS and BroadcastChannel interceptors

Moves the worker surface off the hand-rolled traps and onto the IDL
interceptors from the base PR.

- `shared/broadcastchannel.ts` is new: the channel name was not scoped
  per origin, so two sites proxied through scramjet could see each
  other's messages.
- `shared/opfs.ts` scopes the origin-private file system per site
  origin rather than per proxy origin.
- `worker/importScripts.ts` and `shared/worker.ts` resolve script URLs
  against the site's origin.

Tests: `opfs` and `broadcastchannel-scope`, the latter covering the
cross-origin delivery the unscoped name allowed.


* review and remove comments

---------
2026-09-24 16:45:15 -04:00
velzie 0d629e7595 [core] intercept the Cookie Store API (#116)
* [core] add client.Intercept and the WebIDL type machinery

Groundwork for replacing the RawProxy/RawTrap interceptors with
declarative, IDL-typed ones. No API is migrated here - this lands the
machinery and the differential tests that cover it, so the per-API
rewrites can land in parallel on top.

- `client/webidl.ts`: the IDL type language. Parses signature strings
  into types at compile time, and compiles them into argument
  validators and brand checkers at runtime. `Arguments`, `Returns`,
  `Type` and `Constructor` annotate an interceptor member; unresolved
  or malformed type names surface as a TS error on the annotation.
- `client.Intercept`: takes a class whose members carry those
  annotations, splices it onto the native prototype chain, and wires
  each member through a trampoline so `error.stack` and
  `Function.prototype.toString` keep reporting the native shape.
- `client/nativeerror.ts` + `shared/error.ts`: identify frames from the
  client bundle by a frame taken inside it rather than by file name, so
  stack cleaning holds however the embedder serves the bundle. This is
  what makes `cleanErrors` safe to turn on by default - with it off,
  every intercepted member leaves `scramjet.js` frames in any stack a
  page reads.
- `shared/snapshot.ts`: more pinned natives, all additive.
- `client/shared/origin.ts` + `client/helpers.ts`: the origin model the
  per-API rewrites build on. Replaces `dom/origin.ts` and
  `dom/protocol.ts`.
- `tools/eslint/brand-check-plugin.mjs`: flags interceptor members
  whose IDL annotation and body disagree about brand checking. Wired in
  at "warn" - it will go to "error" once the APIs are migrated.
- `tools/prettier/intercept-hug.mjs`: keeps the class argument of
  `client.Intercept(...)` hugged, so the migrations don't each
  reformat their own file.
- `allowFailedIntercepts` is gone: an interceptor that fails now throws
  rather than silently calling through with unvalidated input.

Three adversarial tests cover the machinery rather than any one API -
`interceptor-nativeness` (the shape of the patches), `webidl-this`
(brand checks and `this` handling) and `interface-object-identity`.

Verified against origin/develop: rspack builds all bundles; tsc goes
43 -> 32 errors over the same file set; the types build goes 7 -> 4
erroring files; eslint goes 276 -> 261 problems. All remaining failures
are pre-existing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* clean up comments

* small fixes

* nits

* preverse __proto__

* [core] intercept the Cookie Store API (#114)

* [core] intercept the Cookie Store API

Split out of the storage PR: this is a new interceptor for an API that
had none, rather than a scoping fix to an existing one.

`CookieStore` read and wrote the proxy's real cookie jar directly, so a
site could see and set cookies belonging to the proxy origin and to
every other site proxied through it.

`shared/cookie.ts` grows `getCookieList`, which returns the matching
records rather than a serialized header. The matching rules are the same
ones `getCookies` already applied for `document.cookie`; the Cookie
Store API needs the attributes as well as the values, so the two now
share one implementation instead of parsing the serialized form back
apart.

`dom/cookie.ts` moves onto the shared matching and onto Intercept.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* clean comments slightly

* [core] address review on the Cookie Store interceptor

`serialize` concatenates `path` into Set-Cookie text but only checked that
it starts with '/'. The spec does not reject a ';' there, so the native
would have accepted one and set it structurally; we build a header, and
`path: "/x;Domain=victim.com;Path=/"` read back out of the jar's parser as
a cookie scoped to another site's host — the same cross-site write this
interceptor exists to close, reached through the one attribute that was
not checked. Rejected now, along with control characters, and thrown
rather than delegated: handing it to the native would write for real.

The domain-match check compared against `client.scopeUrl.hostname` while
`commit` keys the jar write on `client.url`. Those are two different
hosts, and `scopeUrl`'s own docblock says never to compare against it —
an opaque document's is a random `about-opaque://` stand-in. Compare
against the host the write actually lands on.

`get()` and `get({})` are a TypeError per step 5 of the get(options)
steps; they were answering with the first cookie in the jar. `getAll()`
has no such step and keeps returning everything.

Also: `import type` for `Cookie`, no stray const splitting the import
block, no reach through the page's `Array.prototype.indexOf` for the
SameSite enum, `_`-prefixed unused params so eslint is clean, the
brand-check note once above the class rather than deleted entirely, a
note that `partitioned` isolates nothing without a partition key, and
dom/cookie.ts drops the `_Date`/`_URL` import it never used.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QxzkcFw9NRS2XLpgxZwdh9

* [core] drop the brand-check comment again

Restoring it in the previous commit undid acfc2def on purpose-removed
comments; it was not wanted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01QxzkcFw9NRS2XLpgxZwdh9

* [core] drop nativeInit, take WriteInit from the reader

`nativeInit` copied an object we had already built ourselves, field by
field, out of values the readers had already converted — `super.set(init)`
forwards nothing of the page's. And `WriteInit` restated by hand exactly
the shape `readCookieInit` returns, so the CookieInit member list lived in
three places and two of them would go stale the next time the IDL grows a
member.

The half of nativeInit's docblock that is still load-bearing — why a
delegated call cannot have the native write as a side effect — moves onto
`serialize`, next to the return value it is about.

---------
2026-09-24 16:45:15 -04:00
velzieandClaude Opus 5 684e46cfb2 [core] add client.Intercept and the WebIDL type machinery (#102)
* [core] add client.Intercept and the WebIDL type machinery

Groundwork for replacing the RawProxy/RawTrap interceptors with
declarative, IDL-typed ones. No API is migrated here - this lands the
machinery and the differential tests that cover it, so the per-API
rewrites can land in parallel on top.

- `client/webidl.ts`: the IDL type language. Parses signature strings
  into types at compile time, and compiles them into argument
  validators and brand checkers at runtime. `Arguments`, `Returns`,
  `Type` and `Constructor` annotate an interceptor member; unresolved
  or malformed type names surface as a TS error on the annotation.
- `client.Intercept`: takes a class whose members carry those
  annotations, splices it onto the native prototype chain, and wires
  each member through a trampoline so `error.stack` and
  `Function.prototype.toString` keep reporting the native shape.
- `client/nativeerror.ts` + `shared/error.ts`: identify frames from the
  client bundle by a frame taken inside it rather than by file name, so
  stack cleaning holds however the embedder serves the bundle. This is
  what makes `cleanErrors` safe to turn on by default - with it off,
  every intercepted member leaves `scramjet.js` frames in any stack a
  page reads.
- `shared/snapshot.ts`: more pinned natives, all additive.
- `client/shared/origin.ts` + `client/helpers.ts`: the origin model the
  per-API rewrites build on. Replaces `dom/origin.ts` and
  `dom/protocol.ts`.
- `tools/eslint/brand-check-plugin.mjs`: flags interceptor members
  whose IDL annotation and body disagree about brand checking. Wired in
  at "warn" - it will go to "error" once the APIs are migrated.
- `tools/prettier/intercept-hug.mjs`: keeps the class argument of
  `client.Intercept(...)` hugged, so the migrations don't each
  reformat their own file.
- `allowFailedIntercepts` is gone: an interceptor that fails now throws
  rather than silently calling through with unvalidated input.

Three adversarial tests cover the machinery rather than any one API -
`interceptor-nativeness` (the shape of the patches), `webidl-this`
(brand checks and `this` handling) and `interface-object-identity`.

Verified against origin/develop: rspack builds all bundles; tsc goes
43 -> 32 errors over the same file set; the types build goes 7 -> 4
erroring files; eslint goes 276 -> 261 problems. All remaining failures
are pre-existing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01U6ry3tx3Z9nWXwLTJBRDMk

* clean up comments

* small fixes

* nits

* preverse __proto__

---------
2026-09-24 16:45:15 -04:00
velzie 01fd575dbe [core] refactor client.natives with new deterministic prototype proxy (#90)
* [core] refactor client.natives with new deterministic prototype proxy

* fix prototype chaining and old api use
2026-09-24 16:44:35 -04:00
velzie 6971caeb82 [core] refactor htmlRules to use callback instead of attr map 2026-09-24 16:44:35 -04:00
Dmitry Harupaandvelzie a0cf4d3751 fix(core): snapshot String.prototype.startsWith for the URL rewriter (#190)
Co-authored-by: velzie <[email protected]>
2026-07-30 02:42:35 -04:00
Hariz S.andCodex 61037b57ad fix(chrome): make address-bar navigation and suggestions reliable (#120)
Share address parsing and configured search providers between the address bar and new-tab page. Include bookmarks and history, discard stale suggestions, and handle keyboard input and suggestion clicks consistently.

Co-authored-by: Codex <[email protected]>
2026-09-22 21:31:24 -04:00
Hariz S. 72ffaec6a4 [chrome] Make settings pages use Inter like the rest of the interface 2026-09-22 17:02:04 -04:00
Hariz S. d631683bd5 [chrome] Set default search engine to DuckDuckGo 2026-09-22 16:30:21 -04:00
Hariz S.andCodex b640011cbc Fix profile persistence and startup preferences (#101)
* fix(chrome): preserve edits during asynchronous saves

Write one snapshot per flush, retain concurrent changes, and retry failed saves.

Co-authored-by: Codex <[email protected]>

* fix(chrome): persist bookmark and browsing history changes

Validate bookmark drafts without mutating saved URLs. Add searchable, removable history, preserve visit timestamps, and honor clear-history-on-exit. Retain service listeners across garbage collection.

Co-authored-by: Codex <[email protected]>

* fix(chrome): honor startup preferences and preserve pinned tabs

Restore the previous session or open a fresh tab while retaining pins. Supply missing settings defaults and recover from empty saved sessions.

Co-authored-by: Codex <[email protected]>

---------

Co-authored-by: Codex <[email protected]>
2026-09-22 11:48:03 -04:00
percs 75e83d33fc [chrome] add branding variables (#99) 2026-09-21 20:53:54 -04:00
percs 67bbb0182d [chrome] remove favicons from bookmark entries, fetch them from service (#97) 2026-09-20 18:49:23 -04:00
Hariz S. ad1cc5610d [chrome] better styling for rounder, attached tab style 2026-09-19 15:45:48 -04:00
velzie 8c9ccabaa4 [chrome] fix race condition in openUrl 2026-08-06 13:45:11 -04:00
Toshit Chawda d94d0499e9 Fix export escape (#94) 2026-08-06 13:28:10 -04:00
Toshit Chawda adc2ace513 fix anonymous peer 2026-08-05 23:16:50 -07:00
velzie 47be8ab67c [chrome] decode openUrl 2026-08-06 01:26:31 -04:00
Hariz S. 708a7493a2 (chrome) New neutral color schemes (#92) 2026-08-06 00:24:28 -04:00
Neal Shah 5e7e48f5d7 fix referrer check for openUrl (#91) 2026-08-05 18:47:06 -04:00
velzie 6d59f93685 [chrome] fix _blank anchor handler 2026-08-05 18:18:14 -04:00
velzie 8f275ee370 [runway] fix flakes 2026-08-05 15:22:17 -04:00
percs c8856249bd [chrome] fix history update in settings page being hardcoded (#89) 2026-08-05 14:42:23 -04:00
velzie 5f8a3e442b [PUBLISH] 2026-08-05 14:37:45 -04:00
Hariz S. b211e7d25d [chrome] UI Tweakability (#88)
* add ui style option and copy chrome style implementation from early aboutbrowser v2

* tweak chrome style tabs

* change icons and animations in chrome style

* independent tweak options instead of single style switch

* add untracked files

* allow changing whole config through vite config

* updated logic for icon theming stuff
2026-08-04 18:41:45 -04:00
velzie 6a592f4332 [chrome] add anonymous path for puter branding 2026-08-04 02:12:02 -04:00
velzie 35cd0a8117 [runway] another adversarial test pass 2026-07-30 02:41:10 -04:00