Skip to content

Adopt llrt runtime modules: fetch, node:fs/os/zlib/path, crypto, subprocess, Web globals#52

Merged
Mechazawa merged 11 commits into
masterfrom
llrt-migration
Jun 11, 2026
Merged

Adopt llrt runtime modules: fetch, node:fs/os/zlib/path, crypto, subprocess, Web globals#52
Mechazawa merged 11 commits into
masterfrom
llrt-migration

Conversation

@Mechazawa

@Mechazawa Mechazawa commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Replaces the hand-rolled Web-API layer in the plugin runtime with AWS's llrt module crates, pinned to one git rev (5af6d48, all on rquickjs 0.12). Plugins now get a standard runtime surface instead of high-beam-specific approximations.

What plugins see now

  • fetch (gated on the http capability): real Headers/Request/Response/FormData, binary bodies, ReadableStream response bodies. highbeam:http is gone; migration notes live in sdk-reference.md (the gist: get(url, opts) becomes fetch(url, opts) and res.json() is async).
  • node:fs + node:fs/promises behind a new coarse fs capability whose confirm-dialog wording says what it means: full read/write access. Declaring fs also unlocks the scoped highbeam:fs helpers. The narrow fs.read/fs.cache caps are unchanged and do not unlock node:fs.
  • node:path, uncapped.
  • Always-on globals: URL/URLSearchParams, Buffer/Blob/File, multi-encoding TextEncoder/TextDecoder (replaces the UTF-8-only polyfill from Fix plugin runtime gaps, enforce host-only actions, trim comment noise #51), web streams, DOMException, and native AbortController/AbortSignal with working timeout/any/abort statics and spec DOMException reasons.

Host-side changes

  • rquickjs 0.11 → 0.12 (Resolver/Loader gained an ImportAttributes param; the deprecated async_with! macro is replaced by AsyncContext::async_with with async closures at all 27 call sites).
  • The Rust Abort handle drives the native controller directly; the JS controller registry and its release() bookkeeping are deleted.
  • The import allowlist is now highbeam:* plus the three node:* modules; everything else still fails at load with a capability or unknown-module error.

Two deliberate divergences from llrt, both soundness-driven

  1. setTimeout stays our local polyfill. llrt_timers keys its state in a process-global Vec by raw *mut JSRuntime, never deregisters entries, and unwrap_uncheckeds lookups. Under one runtime per plugin plus reloads that leaks and aliases freed state on address reuse. LLRT itself is single-runtime-per-process and never hits this.
  2. AbortSignal.timeout is replaced at install with a setTimeout-backed implementation. Cargo feature unification forces llrt_abort's sleep-timers backend on (llrt_fetch depends on it with defaults), and the native static routes into the table above: calling it aborted the process with unreachable_unchecked in testing.

Fetch guard

llrt_fetch ships no request timeout, so a wrapper joins every request with a 30 s AbortSignal.timeout deadline (caller signals can abort sooner, not later; response size is bounded by the plugin's memoryMb). The adversarial review pass surfaced two gaps the guard now covers, with regression tests: a Request-embedded signal was silently dropped (llrt gives the init arg precedence), and a pre-aborted signal hung the request until transport failure because llrt's fetch only subscribes to future abort sends and AbortSignal.any's already-aborted early-return never fires the sender. Both look like candidate upstream llrt reports.

Cost: release binary grows 16.9 MB → 21.0 MB (hyper, rustls + ring, webpki root store, llrt modules). Known minor residue: each early-finished fetch parks its 30 s deadline timer until it lapses (bounded by fetch rate, cleaned on context teardown); a timers upgrade with real clearTimeout would remove it and is left out of scope here.

Verification: cargo fmt --check, just lint, just lint-pedantic (0 warnings), 338 Rust tests across 16 binaries (new tests/runtime_globals.rs pins the per-capability global surface, node:fs read/write end-to-end, cap gating, the patched AbortSignal.timeout, and both fetch-guard regressions), 260 plugin vitest tests, full-plugin smoke test in the real runtime, release build. A five-lens review workflow (security, soundness, migration correctness, docs accuracy, CI/build) ran over the full diff; all five confirmed findings are fixed here or documented above.

Follow-up: broader llrt surface + highbeam:platform removal

After the initial migration, more llrt modules were wired up and one custom module retired. Each addition was verified against llrt source first (init signature, deps, soundness, binary cost) and gets shape-test pins plus functional probes.

New always-on globals (uncapped, pure compute):

  • crypto: WebCrypto (getRandomValues, randomUUID, subtle.*) plus node-style randomBytes / createHash / createHmac (crypto-rust backend, no C).
  • Temporal: full surface.
  • Intl: partial, DateTimeFormat + supportedValuesOf only (that is all llrt_intl ships, no NumberFormat).

New uncapped node:* modules: node:os, node:zlib (forced to compression-rust, C-free), node:string_decoder.

New subprocess capability: gates node:child_process (spawn) and the process global. process.env is a live proxy over the daemon's real environment; process.exit is inert here (sets __exitCode, which the host does not act on), so a plugin cannot terminate the daemon through it. child_process stdio rides llrt_stream's Rust stream constructors directly, no node:stream registration needed.

Removed highbeam:platform: superseded by node:os. The 6 plugins that used it (app-launcher, kill-process, file-search, dictionary-linux, prefpanes, window-mgmt) now derive platform checks from os.platform() ("darwin"/"linux", not the old "macos"); their vitest suites mock node:os.

Intl and Temporal share jiff (no heavy ICU). Verification after the follow-up: fmt + just lint + pedantic (0 warnings), 354 Rust tests across 17 binaries, 260 plugin vitest tests, full-plugin smoke test in the real runtime.
🤖 Generated with Claude Code

Mechazawa and others added 11 commits June 2, 2026 20:04
Resolver::resolve and Loader::load gained an ImportAttributes parameter;
no other API surface we touch changed. All tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rquickjs 0.12 deprecates the macro in favor of AsyncContext::async_with
with stabilized async closures; -D warnings would fail the build. All 27
call sites converted mechanically with move semantics preserved.

The llrt_* crates (rev 5af6d48, 2026-06-02) resolve and compile against
our feature set; wiring follows in subsequent commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bals

Replaces the hand-rolled Web-API layer with AWS's llrt module crates
(pinned git rev, all on rquickjs 0.12):

- AbortController/AbortSignal are native classes (llrt_abort + DOMException
  from llrt_exceptions). The Rust Abort handle drives them directly; the
  JS controller registry and its release() bookkeeping are gone. Plugins
  gain AbortSignal.any/abort statics and DOMException abort reasons.
  AbortSignal.timeout is replaced at install with a setTimeout-backed impl:
  feature unification forces llrt_abort's sleep-timers backend on, and that
  routes into llrt_timers' process-global runtime table, which is unsound
  under one runtime per plugin (uninitialised lookup hits
  unreachable_unchecked; reload reuse aliases freed state).
- highbeam:http is gone; the http capability now installs the global fetch
  (llrt_fetch: Headers/Request/Response/FormData, binary bodies, streams).
  A guard wrapper injects a 30s default timeout via AbortSignal since
  llrt_fetch ships none; response size is bounded by the plugin memory cap.
- node:fs + node:fs/promises load behind a new coarse `fs` capability
  (full read/write; the confirm dialog says so explicitly). `fs` also
  implies the scoped highbeam:fs helpers. node:path is uncapped.
- URL/URLSearchParams, Buffer/Blob/File, TextEncoder/TextDecoder (full
  multi-encoding, replacing the UTF-8-only polyfill), ReadableStream et al
  are always-on globals via the llrt init fns.
- timers stay a local polyfill: llrt_timers' global state model is the
  same soundness hazard described above.

currency-converter and xkcd migrate from http.get to fetch; their vitest
suites stub the global. The SDK package drops http.js/http.d.ts and
documents that node:* types come from @types/node. New integration suite
(tests/runtime_globals.rs) pins the per-capability global surface, node:fs
end-to-end read/write, cap gating, and the patched AbortSignal.timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fetch section (with highbeam:http migration notes), node:path and
node:fs reference, always-on globals list, new fs capability semantics,
internals stack rationale, and cookbook/tutorial/view examples on fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fold a Request instance's embedded signal into the deadline join: llrt
  gives the init arg precedence, so setting opts.signal silently dropped it.
- Reject pre-aborted signals before any I/O. llrt's fetch only subscribes
  to future abort sends, and AbortSignal.any's already-aborted early-return
  never sends, so a pre-aborted signal hung the request until the transport
  gave up instead of rejecting.
- Docs: fetch timeout rejections carry TimeoutError, not AbortError; add
  the TimeoutError row to the error-name table. Fix the Cargo.toml
  compression comment (zstd stays a C dep under compression-rust).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Abort drops its CancellationToken field and the unused token() /
  is_aborted() getters: nothing outside the module read them, and the
  native controller is already idempotent, so cancel() collapses to the
  single AbortController::abort call.
- The http and fs capability checks in install_host_globals go through
  capability::grants_any, so the fs-implies-scoped-helpers rule reuses
  the same any_of semantics as the module gate instead of hand-rolled
  booleans.
- Re-indent a test block the mechanical http->fetch swap left misaligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rquickjs 0.12 bump replaced the async_with! macro with the
AsyncContext::async_with method at every call site, but left comment and
doc references pointing at the macro form. Surfaced while reconciling the
master merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All uncapped (pure compute, no I/O):
- crypto global: WebCrypto (getRandomValues/randomUUID/subtle.*) plus
  node-style randomBytes/createHash/createHmac (crypto-rust backend, no C).
- Temporal global: full surface (Now/PlainDate/Instant/...).
- Intl global: PARTIAL — DateTimeFormat + supportedValuesOf only, no
  NumberFormat/Collator (that's all llrt_intl ships).
- node:os, node:zlib (compression-rust, C-free), node:string_decoder modules.

Intl/Temporal share `jiff` (no heavy ICU). Shape-test pins the new module
exports; runtime_globals.rs adds functional probes (crypto UUID/random,
Temporal compute, zlib gzip round-trip through Buffer, StringDecoder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`subprocess` grants both: spawning arbitrary programs (node:child_process,
spawn only) and the `process` global, whose `env` is a live proxy over the
daemon's real environment (read + write). Confirm wording: "run and spawn
arbitrary programs, and read or change the launcher's environment".

child_process builds stdio on llrt_stream's Rust constructors directly (no
node:stream registration) and uses the Buffer global. process.exit is inert
here (sets __exitCode, which the host does not act on) so a plugin can't
terminate the daemon through it.

Shape-test pins node:child_process exports; runtime_globals.rs adds cap
gating for both and a real `/bin/sh -c printf` spawn read back through the
stdout stream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
node:os (exposed in the previous commit) supersedes the custom
highbeam:platform module. Removed src/sdk/platform.rs, its SDK stub and
type declarations, the loader arm, the uncapped-module entry, and the
shape-test pin.

The 6 plugins that used it (app-launcher, kill-process, file-search,
dictionary-linux, prefpanes, window-mgmt) now derive their platform
checks from node:os:

    import os from "node:os";
    const isMacOS = () => os.platform() === "darwin";
    const isLinux = () => os.platform() === "linux";

os.platform() returns Node values ("darwin"/"linux"), not the old
"macos". Their vitest suites mock node:os instead of highbeam:platform.
Docs updated to drop highbeam:platform and point platform detection at
node:os.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Mechazawa Mechazawa changed the title Adopt llrt runtime modules: fetch, node:fs, node:path, native Web globals Adopt llrt runtime modules: fetch, node:fs/os/zlib/path, crypto, subprocess, Web globals Jun 5, 2026
@Mechazawa Mechazawa merged commit 80f16a8 into master Jun 11, 2026
14 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