Skip to content

Settle how extensions are installed and loaded before 2.0.0 freezes the mechanism #2400

Description

@dex4er

Goal

Replace the bundled pnpm and the require()-based loader with a lightweight mechanism,
before 2.0.0 freezes the current one by publishing against it.

This is the one part of the v2 extension effort with no issue of its own. #2304 specifies
the API's shape, #2363 fixes its publication, #2365/#2366 enforce its surface.
None of them touch how an extension gets onto disk and into the process.

Intended direction

The contract forbids runtime dependencies. An extension vendors or bundles whatever it
needs, apart from what the host provides.

Everything else follows from that. With no dependencies to resolve, there is nothing for a
package manager to do: fetching an extension is one HTTP request for a tarball, and
installing it is extracting that tarball into a directory. Keeping a 20 MB package manager
to perform a download is not a trade worth making.

This is a direction, not a finished design — the tasks below are what has to be true for it
to hold, and the fourth section lists what the host takes over by giving pnpm up. But the
options are no longer open-ended: "keep pnpm" needs an argument that survives the evidence
in the next section.

Why the contract change is the load-bearing part

Today "declare react/react-dom as peers and do not bundle your own" is a consequence
of how pnpm resolves a tree. It cannot be checked, only relied upon, and it holds only as
long as the resolver behaves identically on every user's machine.

Under a vendor-or-bundle contract the same rule becomes a property of the tarball: an
extension either contains a copy of React or it does not, and that is inspectable before
anything is installed. The rule stops being an emergent behaviour and becomes a promise
with a test — the same move #2395 made for the fetch types, one storey up.

Intended direction — the loader

require() is blocked in the renderer. That is settled, not a candidate.

In main it is a strong recommendation rather than a requirement, to be confirmed once
the renderer side is built and the cost of an async main loader is visible.

What blocking it in the renderer forces

  • Renderer entry points become ESM only. A browser import() cannot load CommonJS.
    Today the loader deliberately accepts both, via Node 24's require(esm) — that latitude
    disappears with require(). This is a contract change and belongs in Extensions API v2 specification (contracts + v1 to v2 migration howto) #2304 next to the
    D2 module-format decision.
  • The bundle has to be reachable by URL, not by path. import("file://…") from a page
    served over https:// is refused by the browser module loader, node integration or not.
    So main serves extension files over a privileged custom scheme (protocol.handle, the
    scheme declared through registerSchemesAsPrivileged) and the renderer imports a URL.
    The application registers no custom protocol today — neither call appears anywhere in
    the tree — so this is new work rather than reuse.
  • The renderer loader becomes async. requireExtension is synchronous and called from
    a .map() inside loadExtensions, which is already async, so the change is contained
    to that method.
  • Top-level await becomes possible in the renderer, which the comment at
    extension-loader.ts:40-46 currently lists as unsupported. Decide whether to allow it,
    and whether allowing it in one process and not the other is acceptable.
  • injectRendererStyles gets simpler. It currently reads the sibling stylesheet a Vite
    library build emits and appends a <style> element. Served over a scheme, that is a
    <link> at a URL and the disk read goes away.
  • The host page's CSP has to admit the scheme in script-src and style-src.

The two halves lock together

This is why the installer and the loader are one issue rather than two, and the reason is
sharper than "they touch the same files":

An extension can only be served over a URL if it does not need node_modules
resolution.
A bare specifier like import "lodash" means nothing to a browser module
loader — there is no resolver behind it. So the vendor-or-bundle contract is not merely a
convenient simplification of the installer; it is a precondition for the loader. Decide
one and the other follows.

On main

Blocking it there too means import(pathToFileURL(entry)), which Node does natively.

What it buys: one module format in the contract instead of two conditional ones, and the
globalThis.require ?? createRequire(import.meta.url) idiom leaves the codebase entirely —
its only other user, path-to-pnpm-cli.injectable.ts, is deleted along with pnpm.

What it costs: main's loader becomes async too, which is exactly what the existing comment
declined to do. That rationale is worth re-reading rather than assuming it still holds.

What this does and does not buy in security

Recorded because the shape above is easy to over-claim, and the over-claim would be load
bearing: it would make #2399 look optional.

Removing require() is not by itself a security control

What the new shape genuinely removes: resolution out of a shared node_modules tree in the
user data directory, where one extension's dependency can shadow another's; a forked
package manager handed the user's whole environment; and implicit inheritance of .npmrc
and registry configuration.

What it does not remove:

  • The extension's code still runs with full privileges. That is inherent to extensions.
    Bundling does not isolate anything — it moves the code into the tarball instead of
    fetching it at install time.
  • contextIsolation: false is still in force. Until Find out what sandboxing the renderer would cost and what it would break #2399 lands, extension code in the
    renderer shares globals with the host page and can reach require through the page
    regardless of how the loader works. So this issue makes the sandbox possible; Find out what sandboxing the renderer would cost and what it would break #2399 is
    what makes it true.
  • The custom scheme is new attack surface. A protocol.handle serving files from disk
    has to refuse path traversal, and whether Electron permits module imports from such a
    scheme without registering it as standard with a correct MIME type needs checking
    rather than assuming.

Auditability: an improvement, not a regression

An earlier draft of this issue treated vendoring as a loss of auditability — you can no
longer read an extension's dependency list to see what it carries. That was wrong, because
the list does not describe that today either.

The reference extension is the illustration:

Field Value
dependencies absent
peerDependencies absent
devDependencies 18 entries
files ["out/**/*"]

The install runs --prod, so devDependencies are never installed. They are a
build-time claim with no enforced relationship to the bundle that actually ships. Whatever
that build inlined is already opaque. So nothing in a manifest describes an extension's
real contents now, and the contract change takes no audit capability away.

It removes an ambiguity instead: today you cannot tell by inspection whether a given
extension is self-contained or partly resolved out of the shared tree, because both are
permitted. Afterwards there is only one answer.

Provenance is the stronger artefact, and it already exists here

It would be easy to describe the trade as "opacity in exchange for trusting the publisher".
That undersells it. A SLSA attestation does not say who to trust — it pins the source the
artefact was built from. Decoded from the reference extension's own published attestation:

subject : pkg:npm/@freelensapp/example-extension@1.10.2
workflow: refs/tags/v1.10.2 · freelensapp/freelens-example-extension · .github/workflows/release.yaml
source  : git+https://github.com/freelensapp/freelens-example-extension@refs/tags/v1.10.2
          gitCommit 4ca939d2ce979ca783655c1c794112a83f9c431e

An exact commit and the workflow that built it. Anything that went into the bundle can be
read from that tree, lockfile included — so the dependency list is reconstructible and
attested
, which is precisely what devDependencies in a manifest is not. The package also
carries a second bundle, npm's own publish attestation.

So the change replaces an unverifiable declaration with a verifiable pointer to the source
that produced the artefact. On auditability that is a gain, not a wash.

The caveat is presence, not principle: an extension published without --provenance has
none of this, and a tarball fetched from a bare URL has no npm attestation at all. Which
makes "is provenance required, warned about, or merely preferred" a contract decision
rather than an implementation detail.

Why this is a 2.0.0 goal and not post-release debt

The mechanism works today, so it is not a shipping blocker in the ordinary sense. It is
still a 2.0.0 goal, because there is no second chance: the moment extensions are
published against v2, the delivery mechanism is part of the contract whether or not anyone
wrote it down.

That is already happening. #2304 is about to freeze contract items that are true only
because of how the installer resolves packages — "extensions declare react/react-dom as
peers ^19 and must not bundle their own" is enforceable because pnpm resolves peers a
particular way into a particular tree. Change the installer later and that item changes
with it, after authors have relied on it.

It is the same failure this effort has now caught twice at a smaller scale: in #2395, a
published Response type that was an accident of how the app was compiled; in #2399, an
extension's access to Node that is an accident of a window flag. This is the same thing one
storey up — the delivery mechanism silently deciding contract.

How it works today

Installer — a bundled pnpm CLI, forked

pnpm is a runtime dependency of the application. path-to-pnpm-cli.injectable.ts
resolves node_modules/pnpm/bin/pnpm.cjs at runtime, and fork-pnpm.injectable.ts forks
it as a child process with PNPM_HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME and
XDG_STATE_HOME redirected into user data.

Fact Value
Bundled pnpm 11.17.0, node_modules/pnpm/dist is 20 MB
npm project root directoryForUserData itself — package.json and node_modules are written directly into the user data directory
Install pnpm install --prefer-offline --prod --force --save-optional <name>
Discovery forks the same CLI: install --prefer-offline --prod --force, and uninstall --force <name>
Registry read by forking pnpm config get registry (get-base-registry-url.injectable.tsx)
Fork environment { ...process.env, <XDG overrides> } — the full environment is inherited

Loader — require() out of that tree

// extension-loader.ts:47
const extensionRequire = globalThis.require ?? createRequire(import.meta.url);

The same idiom appears in path-to-pnpm-cli.injectable.ts:20. Both halves of the mechanism
lean on the renderer being node-integrated or on Node being present in main.

The two are one mechanism

The loader require()s out of the node_modules tree the installer builds. Neither side
can be redesigned alone, which is why this is a single issue rather than two.

How little of this pnpm is actually responsible for

Three measurements, all against main:

  • The package format and extraction are already ours. extract-tar.injectable.ts calls
    extract from the tar package. The install path is attempt-install
    create-temp-files-and-validatevalidate-packageunpack-extension, and pnpm
    appears in none of those steps.
  • installExtensionInjectable has exactly one callerextension-discovery.injectable.ts,
    which runs it after noticing a new directory. The install flow started from the UI never
    invokes it.
  • For the reference extension it installs nothing at all.
    freelensapp/freelens-example-extension, the validation vehicle named in Extensions API v2 specification (contracts + v1 to v2 migration howto) #2304, declares
    no dependencies and no peerDependencies, and publishes files: ["out/**/*"] — build
    output only. It is already a self-contained bundle.

So the 20 MB buys dependency resolution plus a registry lookup, for a case the canonical
extension does not exercise.

What is not wrong with it

Worth recording so the discussion starts from facts rather than reflexes: the installer
does not run arbitrary lifecycle scripts.
pnpm has blocked dependency postinstall by
default since v10, and the bundled version is 11.17.0. The real code-execution surface is
the extension's own entry point once loaded, which is inherent to having extensions at all.

What it constrains elsewhere

What the host takes over by giving pnpm up

Four responsibilities move to us. Three of them collapse into one request the application
already makes
, and the fourth is a settings question.

get-latest-version.injectable.ts already does GET https://registry.npmjs.org/<name>/latest
through downloadJsonproxyFetch, so it goes out honouring the user's httpsProxy,
caCertificates and allowUntrustedCAs preferences today. That response carries
everything the installer needs. Verified against the reference extension:

version:    1.10.2
dist keys:  shasum, tarball, fileCount, integrity, signatures, attestations, unpackedSize
tarball:    https://registry.npmjs.org/@freelensapp/example-extension/-/example-extension-1.10.2.tgz
integrity:  sha512-TNC63mOsVB3NP/QBlk+5JQ2xWKSkxGM67...
  • Resolving a name to a tarballdist.tarball from that response. Removes the root
    cause of Failed to get registry information for extension: Protocol "http:" not supported. Expected "https:" #1610, since the registry stops being discovered by forking
    pnpm config get registry.
  • Version selection and update checks — the same endpoint and the same injectable that
    already checks for new Freelens releases. Extensions get the mechanism the application
    uses on itself, rather than a second one. GitHub releases are the alternative source for
    extensions distributed that way.
  • Integrity verificationdist.integrity, an SRI sha512, from the same response.
    Better still, npm also returns dist.signatures and dist.attestations, which are
    authenticity rather than transfer integrity. This project already verifies GitHub build
    provenance and keyless cosign signatures for its bundled binaries, so checking registry
    signatures is the established pattern here, not a new capability.
  • Private registries and authenticationnot supported initially. The eventual
    form is an explicit application setting, alongside the existing httpsProxy preference,
    rather than today's implicit inheritance from the user's .npmrc and a wholesale
    process.env handed to the forked process. Dropping it deliberately is the point:
    it stops being something that works by accident on one machine and fails silently on
    another.

On the .tgz.sha256 fallback for URL installs

For an extension installed from an arbitrary URL rather than the registry, the
<file>.tgz.sha256 sidecar is the obvious convention and worth supporting. It should be
documented for what it is, though: a checksum fetched from the same host as the tarball
proves the transfer was not corrupted, not that the file is authentic — a compromised
host rewrites both. This repository already draws exactly that line, in the rule that the
kubectl checksum generator reads dl.k8s.io only and never a mirror, "because pinning
bytes from a mirror would let a compromised mirror bless its own digest".

So: registry installs get authenticity, URL installs get integrity, and the difference
belongs in the documentation rather than in a footnote.

Where a URL install points at a GitHub release, the sidecar checksum can be raised to real
authenticity with GitHub build provenance, the same way freelens-k8s-proxy is verified
today. This matters more than it sounds, because of which of the project's three existing
verifiers it is:

Artifact Mechanism Reusable in the shipped app?
freelens-k8s-proxy GitHub build provenance — verify() from the sigstore package over api.github.com/repos/<repo>/attestations/sha256:<digest> yes, pure JS, @sigstore/* is 1.3 MB in total
helm PGP via openpgp yes in principle, not relevant here
kubectl keyless cosign, by forking the external cosign binary (verify.ts:293) no — needs a tool from mise, which is why that check runs at pin time and not in a build

So the proxy verifier is the one to reuse, and it is already written.

And it unifies with the registry path rather than competing with it: npm's dist.attestations
are Sigstore bundles too, issued against the GitHub OIDC identity when a package is
published from GitHub Actions with --provenance. One verifier, one trust root, two ways of
fetching the bundle — from the packument for a registry install, from the attestations API
for a GitHub release.

Cost to weigh honestly: verify.ts currently lives in packages/ensure-binaries, a
build-time package, so this means either extracting the Sigstore path into something
shippable or accepting @sigstore/* as a runtime dependency of the application. 1.3 MB
against the 20 MB of pnpm being removed.

Consequences to accept explicitly

  • Extensions may ship executables, and that is the answer to what a bundler cannot
    inline: a native Node addon is coupled to the host ABI, a standalone binary is spawned
    rather than linked. The contract for it — what may be shipped, how it is invoked, the
    platform matrix, signing — is specified separately in Extensions ABI v2 specification (shipped binaries, process invocation, platform matrix) #2401. Two things land
    here, in the mechanism: extraction must preserve executable mode bits (npm tarballs
    carry them and tar preserves them by default, but that needs testing on all three
    platforms), and shipping binaries makes tarballs large enough that "fetch at first use"
    becomes a plausible alternative — which would put extensions back in the business of
    verifying downloads, so it interacts with the provenance decisions above.
  • Larger tarballs and duplicated code across extensions that bundle the same library.
    Acceptable, and the reason the host-provided list (React, mobx, HTTP) matters.

Tasks

  • Confirm the direction survives contact: check the published Freelens extensions that
    can be surveyed for declared runtime dependencies, and for native modules. The
    reference extension has neither, but it is one data point.
  • Design the lightweight installer: read the packument, verify, fetch tarball, extract,
    done. Both ends already exist — get-latest-version.injectable.ts for the metadata
    request and extract-tar.injectable.ts for the extraction — so this is mostly
    joining them and deleting what sits between.
  • Build the URL-served loader for the renderer: register a privileged scheme, serve
    extension files from main with protocol.handle, make requireExtension async and
    import() the URL. Find out what sandboxing the renderer would cost and what it would break #2399 is blocked on this.
  • Adjust injectRendererStyles to link the served stylesheet instead of reading the
    sibling file, and widen the host CSP to admit the new scheme.
  • Confirm or drop the recommendation to block require() in main as well, once the
    renderer loader exists and the cost of an async main loader is known. Re-read the
    sync-loader rationale at extension-loader.ts:40-46 as part of that.
  • Decide whether top-level await is now allowed in a renderer entry point, and whether
    an asymmetry with main is acceptable if main stays synchronous.
  • Decide how far to take verification: dist.integrity alone, or dist.signatures /
    dist.attestations as well. The second is what the project already does for its
    bundled binaries; the question is whether an extension warrants the same bar.
  • Decide the policy on missing provenance — required, warned about, or accepted
    silently — for registry installs, for GitHub-release URLs, and for bare URLs, which
    can have none. This is the contract's real integrity story now that the dependency
    list has been established as decorative, so it should not be decided by whatever the
    implementation happens to do first.
  • Test that executable mode bits survive packing and extraction on macOS, Linux and
    Windows. The rest of the binary contract — how a shipped executable is invoked, the
    platform matrix, signing — is specified separately in Extensions ABI v2 specification (shipped binaries, process invocation, platform matrix) #2401.
  • Add the private-registry setting when the initial version is done — explicit
    preference, not .npmrc inheritance. Tracked here so dropping it initially does not
    become dropping it permanently by inattention.
  • Remove pnpm as an application dependency once nothing forks it —
    fork-pnpm.injectable.ts, path-to-pnpm-cli.injectable.ts,
    install-extension.injectable.ts, the forkPnpm use in extension-discovery.ts,
    and the registry lookup in get-base-registry-url.injectable.tsx.
  • Write the outcome into Extensions API v2 specification (contracts + v1 to v2 migration howto) #2304 as a contract item: no runtime dependencies, vendor or
    bundle, and what the host provides instead.

Out of scope

Redesigning the extension API surface (that is #2304), and the publication pipeline for
@freelensapp/extensions (#2363). This issue is only about how a user's installed
extension reaches the running application.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions