From 4279446efcbf81c233f7882f9cc017be724c2a27 Mon Sep 17 00:00:00 2001
From: Manzoor Wani <manzoorwani.jk@gmail.com>
Date: Mon, 8 Jun 2026 23:00:56 +0530
Subject: [PATCH] fix(arborist): honor allow-remote=root for root-direct remote
 tarballs (#9510)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](https://github.com/WordPress/gutenberg/pull/75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked` with `allow-remote=root`, a fresh
install fails with `EALLOWREMOTE` on a genuine remote (non-registry)
tarball that is a direct dependency of the project root or a workspace.
The standard (hoisted) reifier installs the same dependency fine under
`allow-remote=root`; only the linked strategy rejects it.

```
npm error code EALLOWREMOTE
npm error Fetching non-root packages of type "remote" have been disabled
npm error Refusing to fetch "@react-native-community/slider@https://raw.githubusercontent.com/wordpress-mobile/react-native-slider/v3.0.2-wp-5/react-native-community-slider-3.0.2-wp-5.tgz"
```

## Why

The `allow-remote=root` gate is enforced at reify time by computing
`_isRoot` and passing it to `pacote.extract` in `reify.js`. A node
counts as "root" if it satisfies at least one valid dependency edge from
the project root or a workspace, which is derived from `node.edgesIn`.
In the linked strategy, store nodes are `IsolatedNode` instances with no
`edgesIn` to recompute root-ness from, so `_isRoot` was always `false`,
every remote tarball was treated as non-root, and pacote refused even a
legitimate root/workspace direct dependency.

This is the sibling of the registry-tarball fix (#9495). That change
carried `isRegistryDependency` onto store nodes so the registry-tarball
exemption still applied; this change carries the analogous root-ness
signal so the `allow-remote=root` gate resolves correctly for genuine
remote tarballs, which are not registry-mediated and so do not qualify
for the registry exemption.

This only widens `allow-remote=root`. `allow-remote=none` still rejects
all remote specs (pacote refuses regardless of `_isRoot`), and a
genuinely transitive remote dependency still fails the resolution-layer
`#checkAllow` gate during ideal-tree construction. Hoisted is unaffected
because its nodes retain real `edgesIn`.

## How

Carry a root-ness flag from the source tree node onto the store node,
rather than weakening the guard:

1. `IsolatedNode` gains an `isRootDependency` field (default `false`),
settable from constructor options.
2. `#externalProxy` computes `isRootDependency` from the real tree
node's `edgesIn` using the same predicate the reifier applies (a valid
edge from the project root or a workspace).
3. `#generateChild` passes it through to the store `IsolatedNode`.
4. The `_isRoot` computation in `reify.js` falls back to
`node.isRootDependency`. Hoisted nodes do not have the property, so they
fall through to the existing edge-based check unchanged.

## References

Fixes #9509
Follows-up #9495

(cherry picked from commit d70e116f01af63bac49ad611cd63ac7709bb0bc5)
---
 .../arborist/lib/arborist/isolated-reifier.js |  5 ++++
 workspaces/arborist/lib/arborist/reify.js     |  3 +-
 workspaces/arborist/lib/isolated-classes.js   |  4 +++
 workspaces/arborist/test/arborist/reify.js    | 30 +++++++++++++++++++
 4 files changed, 41 insertions(+), 1 deletion(-)

diff --git a/workspaces/arborist/lib/arborist/isolated-reifier.js b/workspaces/arborist/lib/arborist/isolated-reifier.js
index ec8309bed57c0..a63f607ef6877 100644
--- a/workspaces/arborist/lib/arborist/isolated-reifier.js
+++ b/workspaces/arborist/lib/arborist/isolated-reifier.js
@@ -45,6 +45,7 @@ module.exports = cls => class IsolatedReifier extends cls {
       isInStore,
       inBundle,
       isRegistryDependency: node.isRegistryDependency,
+      isRootDependency: node.isRootDependency,
       location,
       name: node.packageName || node.name,
       optional: node.optional,
@@ -198,6 +199,10 @@ module.exports = cls => class IsolatedReifier extends cls {
     // Carry the source node's registry-dependency flag so the store node retains it.
     // IsolatedNode has no edges to recompute it from, and reify's registry-tarball allow-remote exemption depends on it.
     result.isRegistryDependency = node.isRegistryDependency
+    // Same reasoning for allow-remote=root: the store node has no edgesIn, so capture from the source node whether it satisfies a valid edge from the project root or a workspace.
+    result.isRootDependency = [...node.edgesIn].some(e =>
+      e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
+    )
     return result
   }
 
diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js
index 4db4413ef8643..c49f352cef140 100644
--- a/workspaces/arborist/lib/arborist/reify.js
+++ b/workspaces/arborist/lib/arborist/reify.js
@@ -744,7 +744,8 @@ module.exports = cls => class Reifier extends cls {
         integrity: node.integrity,
         // A node counts as "root" for allow-* enforcement if it satisfies at least one valid dependency edge declared by the project root or a workspace.
         // node.parent is unsafe here: after hoisting, transitive packages can have the project root as their tree parent.
-        _isRoot: [...node.edgesIn].some(e =>
+        // In the linked strategy the store node has no edgesIn, so isolated-reifier precomputes isRootDependency from the source node's edges.
+        _isRoot: node.isRootDependency || [...node.edgesIn].some(e =>
           e.valid && (e.from?.isProjectRoot || e.from?.isWorkspace)
         ),
         // pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs.
diff --git a/workspaces/arborist/lib/isolated-classes.js b/workspaces/arborist/lib/isolated-classes.js
index 37bb0c82e754c..3a2ade64bbb3e 100644
--- a/workspaces/arborist/lib/isolated-classes.js
+++ b/workspaces/arborist/lib/isolated-classes.js
@@ -22,6 +22,7 @@ class IsolatedNode {
   isInStore = false
   inBundle = false
   isRegistryDependency = false
+  isRootDependency = false
   linksIn = new Set()
   meta = { loadedFromDisk: false }
   optional = false
@@ -55,6 +56,9 @@ class IsolatedNode {
     if (options.isRegistryDependency) {
       this.isRegistryDependency = true
     }
+    if (options.isRootDependency) {
+      this.isRootDependency = true
+    }
     if (options.optional) {
       this.optional = true
     }
diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js
index 420eb43333df3..2b83f918547f1 100644
--- a/workspaces/arborist/test/arborist/reify.js
+++ b/workspaces/arborist/test/arborist/reify.js
@@ -4027,6 +4027,36 @@ t.test('should preserve exact ranges, missing actual tree', async (t) => {
     await t.resolves(arb.reify(), 'registry tarball is allowed under linked strategy')
   })
 
+  t.test('allowRemote=root allows root-direct remote tarball under linked install strategy', async t => {
+    // The linked strategy extracts store nodes as IsolatedNode, which has no edgesIn to recompute root-ness from.
+    // isRootDependency must be carried from the source tree node, otherwise allow-remote=root mis-fires on a genuine remote tarball that is a direct dep of the project root.
+    const testdir = t.testdir({
+      project: {
+        'package.json': JSON.stringify({
+          name: 'myproject',
+          version: '1.0.0',
+          dependencies: {
+            abbrev: 'https://remote.example.com/abbrev-1.1.1.tgz',
+          },
+        }),
+      },
+    })
+
+    tnock(t, 'https://remote.example.com')
+      .get('/abbrev-1.1.1.tgz')
+      .reply(200, abbrevTGZ)
+
+    const arb = new Arborist({
+      path: resolve(testdir, 'project'),
+      registry: 'https://registry.example.com',
+      cache: resolve(testdir, 'cache'),
+      allowRemote: 'root',
+      installStrategy: 'linked',
+    })
+
+    await t.resolves(arb.reify(), 'root-direct remote tarball is allowed under linked strategy with allow-remote=root')
+  })
+
   t.test('registry with different protocol should swap protocol', async (t) => {
     const abbrevPackument4 = JSON.stringify({
       _id: 'abbrev',
