You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
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
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-validate → validate-package → unpack-extension, and pnpm
appears in none of those steps.
installExtensionInjectable has exactly one caller — extension-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.
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 downloadJson → proxyFetch, 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 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 verification — dist.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 authentication — not 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.
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.
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.
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.
Goal
Replace the bundled
pnpmand therequire()-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-domas peers and do not bundle your own" is a consequenceof 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
import()cannot load CommonJS.Today the loader deliberately accepts both, via Node 24's
require(esm)— that latitudedisappears with
require(). This is a contract change and belongs in Extensions API v2 specification (contracts + v1 to v2 migration howto) #2304 next to theD2 module-format decision.
import("file://…")from a pageserved 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, thescheme 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.
requireExtensionis synchronous and called froma
.map()insideloadExtensions, which is alreadyasync, so the change is containedto that method.
extension-loader.ts:40-46currently lists as unsupported. Decide whether to allow it,and whether allowing it in one process and not the other is acceptable.
injectRendererStylesgets simpler. It currently reads the sibling stylesheet a Vitelibrary build emits and appends a
<style>element. Served over a scheme, that is a<link>at a URL and the disk read goes away.script-srcandstyle-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_modulesresolution. A bare specifier like
import "lodash"means nothing to a browser moduleloader — 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 controlWhat the new shape genuinely removes: resolution out of a shared
node_modulestree in theuser 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
.npmrcand registry configuration.
What it does not remove:
Bundling does not isolate anything — it moves the code into the tarball instead of
fetching it at install time.
contextIsolation: falseis still in force. Until Find out what sandboxing the renderer would cost and what it would break #2399 lands, extension code in therenderer shares globals with the host page and can reach
requirethrough the pageregardless 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.
protocol.handleserving files from diskhas to refuse path traversal, and whether Electron permits module imports from such a
scheme without registering it as
standardwith a correct MIME type needs checkingrather 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:
dependenciespeerDependenciesdevDependenciesfiles["out/**/*"]The install runs
--prod, sodevDependenciesare never installed. They are abuild-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:
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
devDependenciesin a manifest is not. The package alsocarries 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
--provenancehasnone 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-domaspeers
^19and must not bundle their own" is enforceable because pnpm resolves peers aparticular 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
Responsetype that was an accident of how the app was compiled; in #2399, anextension'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
pnpmis a runtime dependency of the application.path-to-pnpm-cli.injectable.tsresolves
node_modules/pnpm/bin/pnpm.cjsat runtime, andfork-pnpm.injectable.tsforksit as a child process with
PNPM_HOME,XDG_CACHE_HOME,XDG_CONFIG_HOMEandXDG_STATE_HOMEredirected into user data.node_modules/pnpm/distis 20 MBdirectoryForUserDataitself —package.jsonandnode_modulesare written directly into the user data directorypnpm install --prefer-offline --prod --force --save-optional <name>install --prefer-offline --prod --force, anduninstall --force <name>pnpm config get registry(get-base-registry-url.injectable.tsx){ ...process.env, <XDG overrides> }— the full environment is inheritedLoader —
require()out of that treeThe same idiom appears in
path-to-pnpm-cli.injectable.ts:20. Both halves of the mechanismlean on the renderer being node-integrated or on Node being present in main.
The two are one mechanism
The loader
require()s out of thenode_modulestree the installer builds. Neither sidecan 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:extract-tar.injectable.tscallsextractfrom thetarpackage. The install path isattempt-install→create-temp-files-and-validate→validate-package→unpack-extension, and pnpmappears in none of those steps.
installExtensionInjectablehas exactly one caller —extension-discovery.injectable.ts,which runs it after noticing a new directory. The install flow started from the UI never
invokes it.
freelensapp/freelens-example-extension, the validation vehicle named in Extensions API v2 specification (contracts + v1 to v2 migration howto) #2304, declaresno
dependenciesand nopeerDependencies, and publishesfiles: ["out/**/*"]— buildoutput 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
postinstallbydefault 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
guarantees. They should not be frozen before it is settled.
named there as the blocker, and the answer belongs here.
of resolving the registry by forking
pnpm config get registry.both are lifecycle bugs in this machinery and worth re-reading once it is understood,
rather than fixed in isolation first.
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.tsalready doesGET https://registry.npmjs.org/<name>/latestthrough
downloadJson→proxyFetch, so it goes out honouring the user'shttpsProxy,caCertificatesandallowUntrustedCAspreferences today. That response carrieseverything the installer needs. Verified against the reference extension:
dist.tarballfrom that response. Removes the rootcause 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.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.
dist.integrity, an SRIsha512, from the same response.Better still, npm also returns
dist.signaturesanddist.attestations, which areauthenticity 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.
form is an explicit application setting, alongside the existing
httpsProxypreference,rather than today's implicit inheritance from the user's
.npmrcand a wholesaleprocess.envhanded 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.sha256fallback for URL installsFor an extension installed from an arbitrary URL rather than the registry, the
<file>.tgz.sha256sidecar is the obvious convention and worth supporting. It should bedocumented 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.ioonly and never a mirror, "because pinningbytes 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-proxyis verifiedtoday. This matters more than it sounds, because of which of the project's three existing
verifiers it is:
freelens-k8s-proxyverify()from thesigstorepackage overapi.github.com/repos/<repo>/attestations/sha256:<digest>@sigstore/*is 1.3 MB in totalhelmopenpgpkubectlcosignbinary (verify.ts:293)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.attestationsare 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 offetching the bundle — from the packument for a registry install, from the attestations API
for a GitHub release.
Cost to weigh honestly:
verify.tscurrently lives inpackages/ensure-binaries, abuild-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 MBagainst the 20 MB of pnpm being removed.
Consequences to accept explicitly
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
tarpreserves them by default, but that needs testing on all threeplatforms), 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.
Acceptable, and the reason the host-provided list (React, mobx, HTTP) matters.
Tasks
can be surveyed for declared runtime
dependencies, and for native modules. Thereference extension has neither, but it is one data point.
done. Both ends already exist —
get-latest-version.injectable.tsfor the metadatarequest and
extract-tar.injectable.tsfor the extraction — so this is mostlyjoining them and deleting what sits between.
extension files from main with
protocol.handle, makerequireExtensionasync andimport()the URL. Find out what sandboxing the renderer would cost and what it would break #2399 is blocked on this.injectRendererStylesto link the served stylesheet instead of reading thesibling file, and widen the host CSP to admit the new scheme.
require()in main as well, once therenderer loader exists and the cost of an async main loader is known. Re-read the
sync-loader rationale at
extension-loader.ts:40-46as part of that.an asymmetry with main is acceptable if main stays synchronous.
dist.integrityalone, ordist.signatures/dist.attestationsas well. The second is what the project already does for itsbundled binaries; the question is whether an extension warrants the same bar.
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.
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.
preference, not
.npmrcinheritance. Tracked here so dropping it initially does notbecome dropping it permanently by inattention.
pnpmas an application dependency once nothing forks it —fork-pnpm.injectable.ts,path-to-pnpm-cli.injectable.ts,install-extension.injectable.ts, theforkPnpmuse inextension-discovery.ts,and the registry lookup in
get-base-registry-url.injectable.tsx.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 installedextension reaches the running application.