feat(electron): upgrade to Electron 43 and restore plugin loading and HTTP - #50
Open
CR0CKER wants to merge 1 commit into
Open
feat(electron): upgrade to Electron 43 and restore plugin loading and HTTP#50CR0CKER wants to merge 1 commit into
CR0CKER wants to merge 1 commit into
Conversation
… HTTP 41.x reached end of life on 2026-08-25; 43.x is supported until 2027-01-05. Restores the two things the newer Chromium breaks, and changes nothing about what a plugin is allowed to reach. 43 rather than 44 because 44.0.0 was two days old when this was prepared and 43.4.1 is a matured patch line. better-sqlite3 12.10.0 no longer compiles against Electron 43's V8 -- its SetNativeDataProperty call is ambiguous against the three overloads now declared in v8-template.h. 13.0.3 fixes that and ships N-API prebuilds, so it no longer needs an electron-rebuild pass per Electron major. node-abi bumped to 4.33.0, and the root yarn.lock regenerated so it agrees with the bumped devDependency (net -255 lines: @electron/get 5 drops transitives that version 2 pulled in). Note for packagers: from Electron 42 the npm package no longer fetches its binary in a postinstall script -- it downloads on first use, so offline or sandboxed builds need to prime the cache explicitly. PLUGIN LOADING. Electron 40 tightened opaque-origin rules. Loading the renderer from file:// makes the parent origin opaque, which breaks the postMessage handshake plugin iframes rely on, so no plugin loads at all. The renderer is served over the privileged lsp:// scheme instead. That means lsp:// can no longer serve static app files from one flat root, so the routes are namespaced into /plugins/ (dot-root installs) and /external/<urlencoded-root>/ (plugins installed elsewhere), with everything else still resolving against __dirname. The bare legacy form lsp://logseq.io/<pid>/... is still accepted: themes register under it and their URLs are persisted in preferences.json and localStorage, so rejecting it silently breaks every installed theme on upgrade. Ported from logseq/logseq#12741. PLUGIN HTTP. Serving the renderer over lsp:// gives plugin frames a real tuple origin, so Chromium began enforcing CORS on requests they make; under file:// none was applied and the same calls worked. Most endpoints a plugin talks to send no Access-Control-Allow-Origin at all -- ordinary web pages, local APIs like Zotero or Syncthing -- or omit the plugin's custom client header from Access-Control-Allow-Headers, so the request fails before it is sent. Observed: Readwise sync, url-title-tagger, and Syncthing/Zotero-backed plugins (logseq/logseq#12736). Two halves. The SDK installs a fetch bridge in plugin frames that routes http(s) fetch through the main process, sidestepping the browser's CORS layer rather than relaxing it; :httpRequest gains includeResponse so a real Response can be rebuilt with its status, headers and url. Anything the bridge cannot carry faithfully keeps the native implementation rather than being silently altered: non-http(s) URLs, credentials:'include' (a main-process request has no cookies), and a non-string body such as FormData/Blob/ArrayBuffer, which the handler would JSON-serialise into "{}". AbortSignal is wired to the host's own abort path, and an abort is never retried natively. A string body is also no longer double-encoded by JSON.stringify, which broke the common fetch(url, {body: JSON.stringify(x)}) shape. The other half is a permissive CORS response for the requests the bridge cannot see -- XHR, and anything issued before the bridge installs. It publishes a WILDCARD origin, deliberately, not the echoed request origin: the browser rejects "*" for credentialed requests, so cookie-bearing cross-origin reads stay blocked. Echoing the origin would let a plugin read another site's authenticated responses -- a real escalation. Access-Control-Expose-Headers is published too, since under file:// a plugin could read every response header and the CORS-safelisted six are not enough for the Link/ETag/X-RateLimit headers real APIs answer with. Otherwise this grants no new capability: any plugin can already issue unrestricted HTTP via logseq.Request -> exper_request -> node-fetch in the main process, with no CORS at all. Unlike webSecurity:false, same-origin DOM access and mixed-content blocking are untouched. The relaxation is scoped and fails closed. The main renderer is ALSO lsp://logseq.com (electron.html), so matching on scheme alone would relax the app's own requests -- plugin paths are matched specifically, and only xhr/other resource types. Identity is resolved in onBeforeRequest, where the frame is still alive, and recorded against the webRequest id; Electron documents details.frame as nullable once a frame has navigated or been destroyed, which is exactly its state at onHeadersReceived time. Anything not positively attributed is left untouched. The onBeforeRequest listener never cancels a request -- it only records attribution -- so plugin network reach is exactly what it was before. It is filtered to http(s): an unfiltered listener also sees the renderer's own lsp:// asset loads and stalls them, and the app never finishes starting. logseq.Request also now initiates over the postMessage caller rather than Experiments.invokeExperMethod, which does a synchronous window.top.logseq read -- that throws for a plugin iframe on a different origin than the host, i.e. every :effect false plugin, so requests never started there. Ported from logseq/logseq#12753. PATH CONTAINMENT. The new /external/ route takes the directory to serve from out of the URL, so containment alone cannot decide whether that directory may be read at all: resolveWithin faithfully confirms that id_ed25519 is inside ~/.ssh. A URL may therefore only name a root the app already knows about -- preferences.json's "externals", plus <dot-root>/tmp, where the SDK generates the entry document for a plugin whose package main is a .js file. That second root is not optional: such a plugin addresses its generated entry with its own directory as the root, which is never an "external", so leaving it out refuses the entry and the plugin does not load at all. It is app-controlled rather than named by the URL, so trusting it adds no reach. The list is seeded at startup and re-read when the handler meets an unrecognised root (throttled to once a second, so a stream of bogus roots cannot hammer the disk). Re-reading is not enough for a FIRST install, though, and load order is why: PluginLocal#load() mounts the plugin's frame -- which fetches its entry document and then the plugin's own scripts over lsp:// -- BEFORE LSPluginCore registers it and writes preferences.json. At that moment the file is not yet a record of anything, so nothing on disk can authorise the plugin being installed. The authority there is the directory the user picked, so the load-unpacked-plugin flow gets its own IPC (:openPluginDirDialog) that opens the same dialog and allows the chosen directory for the rest of the session. It is deliberately not a renderer-callable "trust this path" API: the only path it ever accepts is one the main process watched the user choose in a file dialog. From the next launch preferences.json covers it. Every branch of the handler additionally resolves through resolveWithin, which also closes the pre-existing traversal in the plugin branch. RELEASE-BUILD TRAPS, all invisible to a dev compile. utils.js is Closure-compiled under :advanced in a release build, which renames any path.* PROPERTY it has no extern for: path.sep and path.relative compile to mangled keys that are undefined on Node's real path object, so a containment check written with path.sep returns null for EVERYTHING -- including the app's own electron.html, giving a blank window. Containment is done with plain string ops instead. externs.js gains onBeforeRequest/resourceType/frame for the same reason: without them the call fails with "$onBeforeRequest$ is not a function", which aborts app setup before the 'main' IPC channel registers and leaves the renderer dead on arrival. Registration is wrapped so a failure degrades to "no attribution" rather than taking the app down. The same renaming bites a property read on PARSED DATA, where there is nothing to write an extern against: preferences.json's `externals`, read as prefs.externals, compiled to a mangled key that is undefined on the real object -- so only the tmp root was seeded and every plugin installed outside the dot-root was refused. It is read as prefs['externals']; a string literal cannot be renamed. Caught by running the packaged app, not by the suite, so there is now a guard over the compiled output for it too. Note the trap inside the trap: a pseudo-named build spells the BROKEN form `.$externals$`, which still contains the word, so grepping the bundle for "externals" proves nothing -- the guard asserts the absence of the mangled form. Adds test/electron-js (46 cases, bare `node --test`, no Electron or build step) covering containment, the external-root allowlist and its re-seed path, and which requests are relaxed plus the header rewrite itself. It lives outside the deps.edn :paths so it stays off the shadow-cljs compile surface, is chained into `npm test`, and runs as its own step in the build workflow, whose test job invokes yarn cljs:test directly rather than yarn test. Two of those cases are guards over the COMPILED output, since neither trap above is observable any other way. They read static/electron.js when it exists and was built with pseudo-names (`--debug`); a plain release renames the symbols away entirely, leaving nothing to match on, so that case is skipped explicitly. When the build IS observable, a missing marker FAILS rather than skipping -- a guard whose marker has gone stale silently protects nothing. The marker anchors on resolveWithin's DEFINITION rather than its export line, because Closure emits the export as a bare alias once the function has a second caller inside the module. Verified against a real advanced build (`clojure -M:cljs release electron --debug`): resolveWithin compiles to plain string comparisons with path.resolve/path.join intact and no path.sep, and onBeforeRequest / onHeadersReceived / responseHeaders / resourceType all survive unrenamed. Fixes logseq#32. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 tasks
|
I have pulled in the current state of this PR as a patch into NixOS/nixpkgs#516682, (minus the |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #32.
What changed
Upgrades Electron 41.7.1 → 43.4.1 and restores plugin loading and plugin HTTP, both of which the
newer Chromium breaks. No change to what a plugin is allowed to reach.
Electron 41.7.1 → 43.4.1. 41.x reached end of life on 2026-08-25; 43.x is supported to
2027-01-05.
better-sqlite312.10.0 no longer compiles against Electron 43's V8(
SetNativeDataPropertyis ambiguous against the three overloads now inv8-template.h);13.0.3 fixes that and ships N-API prebuilds, so it no longer needs an
electron-rebuildpassper Electron major.
node-abibumped to 4.33.0. 43 rather than 44 because 44.0.0 was two daysold when this was prepared and 43.4.1 is a matured patch line; 44 is a straightforward
follow-up once it has settled.
Renderer served over
lsp://instead offile://. Electron 40 tightened opaque-originrules: a
file://renderer makes the parent origin opaque, which breaks thepostMessagehandshake plugin iframes rely on, so no plugin loads at all.
lsp://can no longer servestatic app files from one flat root, so routes are namespaced into
/plugins/(dot-rootinstalls) and
/external/<urlencoded-root>/(plugins installed elsewhere), with everythingelse resolving against
__dirname. The bare legacy formlsp://logseq.io/<pid>/...is stillaccepted — themes register under it and their URLs are persisted in
preferences.jsonandlocalStorage, so rejecting it silently breaks every installed theme on upgrade. Ported fromchore: bump electron logseq#12741.
Plugin HTTP restored, two ways. Serving the renderer over
lsp://gives plugin frames areal tuple origin, so Chromium began enforcing CORS on requests they make; under
file://none applied and the same calls worked. Most endpoints a plugin talks to send no
Access-Control-Allow-Originat all (ordinary web pages, local APIs such as Zotero orSyncthing) or omit the plugin's custom client header from
Access-Control-Allow-Headers.Observed breakage: Readwise sync, url-title-tagger, and Syncthing/Zotero-backed plugins
(Bug (?) / Help With CORS Checks For Plugins logseq#12736).
fetchbridge in the SDK (LSPlugin.user.ts) routes http(s)fetchfrom a pluginframe through the main process, which sidesteps the browser's CORS layer rather than
relaxing it. The
:httpRequesthandler gainsincludeResponseso the bridge can rebuild areal
Responsewith its status, headers and URL. Anything the bridge cannot carryfaithfully keeps the native implementation instead of being silently altered: non-http(s)
URLs (
lsp://assets,data:,blob:, relative paths),credentials: 'include'(amain-process request has no cookies), and a non-string body such as
FormData,BloborArrayBuffer(the handler would JSON-serialise it into{}).AbortSignalis wiredthrough to the host's own abort path, and an abort is never retried on the native path.
anything issued before the bridge installs. It publishes
Access-Control-Expose-Headers: *alongside the wildcard origin, since underfile://aplugin could read every response header and the CORS-safelisted six are not enough for the
Link/ETag/X-RateLimitheaders real APIs answer with.logseq.Requestinitiates overpostMessage. It usedExperiments.invokeExperMethod, which does a synchronouswindow.top.logseqread — thatthrows for a plugin iframe on a different origin than the host, i.e. every
:effect falseplugin, so requests never started. Ported from fix: initiate logseq.Net HTTP requests over postMessage so they work from sandboxed plugin iframes logseq#12753. The abort path already
went through the caller.
A string request body is no longer double-encoded.
handler.cljsranJSON.stringifyover a body that was already a string, breaking the common
fetch(url, {body: JSON.stringify(x)})shape.Path containment on the
lsp://handler (resolveWithin), plus a startup allowlist oflegitimate external plugin roots.
Why the CORS relaxation is not a new capability
A wildcard origin is published, deliberately, not the echoed request origin. The browser
rejects
*for credentialed requests, so cookie-bearing cross-origin reads stay blocked. Echoingthe origin would let a plugin read another site's authenticated responses — a real escalation.
Beyond that it grants nothing new: any plugin can already issue unrestricted HTTP via
logseq.Request→exper_request→node-fetchin the main process, with no CORS at all.Browser CORS was never a boundary against a hostile plugin here, only a tax on honest ones using
plain
fetch. UnlikewebSecurity: false, the same-origin policy for DOM access andmixed-content blocking are untouched.
The relaxation is scoped and fails closed:
lsp://logseq.com(electron.html), somatching on the scheme alone would relax the app's own requests. Plugin paths are matched
specifically.
xhr/otherresource types, which are the ones that read a cross-origin body.onBeforeRequest, where the frame is still alive, and recorded againstthe webRequest id. Electron documents
details.frameas nullable once a frame has navigated orbeen destroyed, which is exactly the state it can be in at
onHeadersReceivedtime. Anythingnot positively attributed is left untouched.
onBeforeRequestlistener never cancels a request; it only records attribution, soplugin network reach is exactly what it was before.
Why path containment is part of this PR
The
/external/<urlencoded-root>/route is new here, and it takes the directory to serve fromout of the URL.
resolveWithinverifies that the file stays inside the root it was given —but the URL chose that root, so a request naming
~/.sshand the fileid_ed25519passescontainment cleanly. Containment answers "is the file inside the directory?", never "may you read
that directory at all?".
So a URL may only name a root the app already knows about:
preferences.json'sexternals— theSDK's own record of installed external plugins — plus
<dot-root>/tmp, where the SDK generatesthe entry document for a plugin whose package
mainis a.jsfile. That second one is notoptional: such a plugin addresses its generated entry with its own directory as the root, which
is never an "external", so without it the entry is refused and the plugin does not load. The tmp
dir is app-controlled rather than named by the URL, so trusting it adds no reach.
The list is seeded at startup and re-read when the handler meets a root it does not recognise
(throttled to once a second, so a stream of bogus roots cannot hammer the disk). A root still
unknown after that re-read is refused.
Re-reading is not enough for a first install, and load order is why:
PluginLocal#load()mounts the plugin's frame — which fetches its entry document and then the plugin's own scripts
over
lsp://— beforeLSPluginCoreregisters it and writespreferences.json. At thatmoment the file is not yet a record of anything, so nothing on disk can authorise the plugin
being installed. The authority there is the directory the user picked, so the
load-unpacked-plugin flow gets its own IPC (
:openPluginDirDialog) that opens the same dialogand allows the chosen directory for the rest of the session; from the next launch
preferences.jsoncovers it. It is deliberately not a renderer-callable "trust this path" API —the only path it ever accepts is one the main process watched the user choose in a file dialog.
Without any of this the PR would introduce a wider traversal than the one that exists today; the
pre-existing traversal in the plugin branch is closed by the same
resolveWithincall.Release-build trap worth knowing
src/electron/electron/utils.jsis Closure-compiled under:advancedin a release build,which renames any
path.*property it has no extern for.path.sepandpath.relativebothcompile to mangled keys that are
undefinedon Node's realpathobject, so a containment checkwritten with
path.sepsilently returns null for everything — including the app's ownelectron.html, giving a blank window. Containment is therefore done with plain string ops. A devcljs compiledoes not rename properties and cannot catch this; only areleasebuild will.The same renaming bites a property read on parsed data, where there is nothing to write an
extern against:
preferences.json'sexternals, read asprefs.externals, compiled to a mangledkey that is
undefinedon the real object — so only the tmp root was seeded and every plugininstalled outside the dot-root was refused. It is read as
prefs['externals']; a string literalcannot be renamed. This one was caught by running the packaged app, not by the suite, so there is
a guard over the compiled output for it too. Note the trap inside the trap: a pseudo-named build
spells the broken form
.$externals$, which still contains the word, so grepping the bundle forexternalsproves nothing — the guard asserts the absence of the mangled form.externs.jsgainsonBeforeRequest/resourceType/framefor the same reason — without themthe call fails at runtime with
$onBeforeRequest$ is not a function, which aborts app setupbefore the
mainIPC channel registers, leaving the renderer dead on arrival. Registration isadditionally wrapped so a failure degrades to "no attribution" rather than taking the app down.
Testing instructions
yarn install yarn test:electron-js # new; also chained into `yarn test`46 cases under bare
node --test— no Electron, no browser, no build step — coveringresolveWithincontainment (traversal, absolute-looking paths, the sibling-directorystartsWithtrap), the external-root allowlist, and which requests get relaxed (all three plugin frame forms;
the app frame explicitly not), the external-root allowlist and both the re-seed and
user-chosen-root paths into it, and the CORS header rewrite itself. Verified green on Node 22 and 24.
The suite was mutation-checked: replacing the containment check with a naive
startsWith, andwidening frame matching to the whole
lsp://scheme, each turn it red.Three of the 46 cases are guards over the compiled output, since neither release-build trap
above is observable any other way. They read
static/electron.jswhen it exists and was builtwith pseudo-names (
clojure -M:cljs release electron --debug); with no such build present thereis nothing to inspect and they no-op, so they protect a developer who has just run a debug release
build and nobody else. When the build is observable, a missing marker fails rather than
skipping — an earlier version of this guard used a marker that matched no build at all and so
passed unconditionally.
The suite is wired into
.github/workflows/build.ymlas its own step, since that job runsyarn cljs:testdirectly rather thanyarn test.Also run:
clojure -M:clj-kondo --lint src/electron— 0 errors (one pre-existing unused-referralwarning in
utils.cljs, unchanged by this PR).A release build has been run and verified:
resolveWithincompiles to plain stringcomparisons with
path.resolve/path.joinintact and nopath.sep, theexternalsreadsurvives unmangled, and
onBeforeRequest/onHeadersReceived/responseHeaders/resourceTypeall survive unrenamed.Exercised manually on a packaged Linux/aarch64 build: 15 dot-root plugins across both the
logseq.com/plugins/andlogseq.io/plugins/routes; themes rendering correctly (checkedagainst 0.10.15 with an A/B of computed styles over CDP); Readwise sync, i.e. plugin HTTP, working
again; both external plugins loading over the
/external/route — one with an HTML entry and onewith a
.jsentry, freshly installed through Load unpacked plugin and again after a restart, soboth the dialog and the
preferences.jsonpaths into the root list are covered; and aFTS5/trigger/
MATCHsmoke test ofbetter-sqlite313 under Electron 43.macOS and Windows packaging are untested — I don't have the hardware.
Upgrade note for release notes
The renderer origin moves from
file://tolsp://logseq.com, andlocalStorageis keyed byorigin, so UI preferences silently reset to defaults on first launch after upgrading. The visible
one is
radix-colorreturning tologseq, which activates the built-in Solarized background andoverrides theme plugins; sidebar widths reset the same way. Nothing is lost — the graph and
~/.logseq*are untouched — but it looks alarming without a note.Known limitations
round-tripped, and
redirected/typeare not reconstructed (status,statusText, headersand
urlare). Requests the bridge declines —credentials: 'include', non-string bodies —take the native path and are therefore subject to CORS as any web page would be.
OPTIONSstill fails preflight for a non-simple XHR. Bridgedfetchis unaffected, so this is confined to plugins issuing XHR directly.Scope
Deliberately excluded: the per-plugin network permission model this work was originally built
alongside. It restricts plugins more than today's behaviour and is not required by the
upgrade — it belongs in its own PR with its own discussion. This PR restores parity and nothing
else.