Skip to content

Add realm-aware trusted scripts and native execution-state queries - #106

Merged
syrusakbary merged 10 commits into
mainfrom
feat/trusted-script
Nov 6, 2026
Merged

syrusakbary merged 10 commits into
mainfrom
feat/trusted-script

Conversation

@syrusakbary

@syrusakbary syrusakbary commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem and behavior

Embedders need direct eval to accept a native branded source without losing its caller's lexical scope, and need a reliable query for JavaScript execution across realms. This adds:

napi_status unofficial_napi_create_trusted_script(napi_env env, napi_value source, napi_value* result);
napi_status unofficial_napi_is_executing_js(napi_env env, bool* result);

Both follow the merged realm model: one env serves the engine instance, creation uses the current N-API realm, and execution-state queries cover the agent's realms. The full contract is in docs/trusted-scripts.md.

Implementation

  • V8 owns its unforgeable private brand and shared code-like template on napi_agent__, without global maps or locks. The template does not retain its first creation realm; cleanup hooks can still use the feature while the agent is live.
  • Direct eval(trusted) reads the stored primitive string, preserves caller scope, and bypasses author toString/Symbol.toPrimitive. Brands cannot be copied, inherited, forged by name, or forwarded through proxies. Eval uses the receiving realm's code-generation policy.
  • The V8 query checks JavaScript frames across security tokens and microtask checkpoints. Scoped agent-wide checkpoint state covers own realm queues even when a callback acts in another realm.
  • QuickJS now implements the query from native VM state. The pinned engine exposes JS_IsExecutingJS(JSRuntime*), recognizes ordinary/generator/async/async-generator bytecode frames, and tracks nested job execution through cleanup. C-only calls, suspended coroutines and queued jobs report false. Native-only executing jobs report true; errors and termination unwind the state. The query allocates nothing and does not inspect/clear exceptions.
  • Both providers return napi_ok for the query and preserve pending exceptions, including during termination. Entering a realm alone reports false. Calls stay on the owning thread; the bridge invokes neither operation under g_mu.
  • Guest imports validate pointer bounds and write a four-byte value id or one-byte bool. Host-JS unsupported stubs still zero valid outputs.

Engine dependency and limits

The QuickJS pin advances from 870aa7b to 883881a, published in wasmerio/quickjs#11. That dependency is merged into QuickJS master as 78817fd; the pinned 883881a is included in that merge. It contains the small native query and runs its focused C regression through the existing make test target.

Provider create_trusted_script is_executing_js
V8 Implemented Implemented
QuickJS Explicitly unsupported (napi_generic_failure, NULL output) Implemented
Host JS Explicitly unsupported, zeroed output Explicitly unsupported, false output

QuickJS trusted scripts require native eval unwrapping, an unforgeable GC-traced brand and receiving-realm code-generation policy; a provider shim cannot preserve direct-eval scope. Its existing runtime job queue is unchanged; this does not add V8-style per-realm queues.

Function(trusted) uses ordinary argument coercion, so author overrides can change its source. Only direct eval has the hardened conversion guarantee. The first V8 creation replaces an earlier embedder code-generation hook (V8 has no getter for chaining); a later hook is preserved. The hook cannot enforce a trusted-only policy while ordinary string code generation is allowed. The V8 query still captures a stack trace per call.

Validation

macOS arm64, based on main 18209d2:

Check Result
Full V8 Release CTest suite 379 passed
Full QuickJS Release CTest suite 313 passed
Feature runner 23 V8 / 7 QuickJS cases passed
V8 feature runner with AddressSanitizer 23 passed
QuickJS feature runner with Debug ASan + UBSan 7 passed
QuickJS focused engine regression Passes Release and Debug ASan + UBSan
QuickJS bundled conformance tests 0/120 errors, 10 excluded
New execution-state program Passes native V8, native QuickJS, and WASIX through V8 CLI

The broader QuickJS Debug ASan + UBSan suite has 309 passes and four failures, all reproduced on the unchanged 317ed89 PR head with QuickJS 870aa7b: three invalid-enum UBSan failures (error metadata and module-state validation) and RecursionThroughRealms hitting the Debug sanitizer stack limit. These are documented existing failures, not a claimed clean full sanitizer run. ASan uses detect_leaks=0; UBSan uses halt_on_error=1.

The preceding realm-adaptation commit 317ed89 also passed the full V8 ASan suite (375 passes, one existing sanitizer-specific skip), Rust library (63), concurrency/store/guest-callback integration (12 + one ignored / 2 / 4), host-JS node (49), host-JS wasm-bindgen (30), fmt and clippy. Its extended trusted-script program passes natively and through WASIX. This follow-up changes no Rust or host-JS implementation. No full manifest sweep, application build or performance improvement is claimed.

syrusakbary and others added 7 commits October 22, 2026 12:47
…uting_js

Embedders implementing web platform semantics need two things N-API does
not offer.

Trusted Types' TrustedScript objects must be valid eval/Function sources:
direct eval has to keep the caller's lexical scope, and author code must
not change the evaluated source through toString, Symbol.toPrimitive, a
prototype swap or a forged copy. unofficial_napi_create_trusted_script
returns a V8 code-like object (ObjectTemplate::SetCodeLike) that keeps the
source under a private symbol. A ModifyCodeGenerationFromStrings callback
hands V8 that source for branded objects and changes nothing for other
values, so unbranded objects are still returned by eval unchanged and a
context with string code generation disabled still throws EvalError. The
default prototype's native toString makes Function(trusted) work until an
embedder installs its own prototype.

HTML algorithms branch on whether the JavaScript execution context stack
is empty. unofficial_napi_is_executing_js reports true when V8 has a
JavaScript frame (across security origins) or a microtask checkpoint is
running, including a contextify context's own queue. It runs no
JavaScript and keeps any pending exception.

QuickJS and the host-JS provider return napi_generic_failure for both
(unsupported). Guest imports take u32 ids and result pointers and check
the output pointer before calling the provider.

Tests: a new test_66_unofficial_trusted_script runner (seven V8 cases, one
QuickJS case) and a test_trusted_script manifest program that runs
natively and as WASIX through napi_wasmer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unofficial_napi_create_trusted_script built a new FunctionTemplate and
instance template on every call. V8 caches each template instantiation
in the native context, so the first calls stayed reachable after their
trusted scripts were gone: used heap after GC grew by about 750 KB over
20k calls before the cache stopped growing. Keep one code-like
ObjectTemplate per environment (napi_env__::trusted_script_template) and
reuse it. The same 20k calls now retain about 1.6 KB.

Behavior is unchanged: each trusted script still gets its own prototype
with the native toString.

CreationDoesNotRetainMemory creates 20k trusted scripts and checks used
heap after GC grows by less than 128 KB; it fails without the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header now states that unofficial_napi_create_trusted_script installs
an isolate-wide ModifyCodeGenerationFromStrings callback on every call,
replacing any callback the embedder installed. V8 only exposes a setter
for this callback, so the provider cannot read the previous one to chain
it. For other values the hook keeps V8's behavior.

It also states exactly what unofficial_napi_is_executing_js reports: a
JavaScript frame on the VM stack (any context or security origin) or a
running microtask checkpoint. A native function that C calls through
napi_call_function with no JavaScript frame below it reports false;
IsExecutingJsReportsFramesAndMicrotasks now pins that case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The brand was a v8::Private::ForApi symbol with a fixed name. ForApi
symbols live in an isolate-wide registry, and
unofficial_napi_create_private_symbol returns ForApi(desc) for any
description, so code holding a private symbol made with that name could
build a plain object that eval() treated as a trusted script, or rewrite
a real trusted script's source.

The brand is now a v8::Private::New symbol created once per isolate and
kept in a provider map (dropped when the environment is destroyed), so
nothing else can name it. The hook also requires the object to be
code-like, which only the provider's template produces.

BrandCannotBeForgedWithPrivateSymbol creates private symbols with the old
and new descriptions, and checks that a forged object is returned
unchanged by eval and that a real trusted script keeps its source; both
fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unofficial_napi_create_trusted_script reinstalled its isolate-wide
ModifyCodeGenerationFromStrings callback on every call, so a callback the
embedder installed later was silently overwritten by the next trusted
script. Install it only when the isolate's brand key is first created.
The header now says the first call replaces an existing embedder hook
(V8 offers no getter, so it cannot be chained) and that a hook installed
afterwards is kept.

KeepsEmbedderHookInstalledLater installs an embedder hook after the first
trusted script and checks that a later trusted script follows it; it
fails without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
unofficial_napi_is_executing_js took the process-wide g_context_mu and
scanned every environment's contextify records on each call, so worker
isolates contended on it, and a call from a context token callback (which
runs with that mutex held) deadlocked.

A contextify context's own microtask queue is private to the provider and
drained only by unofficial_napi_contextify_run_script. That drain now
bumps a per-environment depth (napi_env__::contextify_microtask_checkpoints)
on the owning thread, and is_executing_js reads it instead of scanning
queues. NapiV8IsAnyContextifyMicrotaskQueueRunning is gone.

IsExecutingJsFromContextTokenCallback calls is_executing_js from a context
token assign callback; it deadlocks without the fix. Removing the depth
update fails IsExecutingJsSeesOtherContexts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move-only header change. The declarations sat directly above the
unofficial_napi_enqueue_microtask comment, which the context-activation
PR (feat/contextify-enter-leave) also edits; with this move the two PRs
merge without conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@syrusakbary

Copy link
Copy Markdown
Member Author

Note: I'm not sure we should merge this

@syrusakbary

Copy link
Copy Markdown
Member Author

Part of this PR can be simplified once #101 lands

@syrusakbary syrusakbary changed the title Add unofficial_napi_create_trusted_script and unofficial_napi_is_executing_js Add realm-aware trusted scripts and native execution-state queries Nov 6, 2026
@syrusakbary
syrusakbary merged commit 49ae989 into main Nov 6, 2026
11 checks passed
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