From d8486df6c966f6a25f94a99cd57b620a296da919 Mon Sep 17 00:00:00 2001 From: kdm1jkm Date: Mon, 28 Sep 2026 02:31:43 +0900 Subject: [PATCH 1/2] fix(update): allow Scoop Node npm from user home --- .../src/content/docs/reference/cli/lifecycle.md | 4 ++++ src/update/npm-invocation.mjs | 10 +++++++++- structure/ops/service-and-sidecars.md | 5 +++++ tests/update/update-npm-invocation.test.ts | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 64c9eedd31e..1c0820dbae2 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -827,6 +827,10 @@ Unix-only check. A failure aborts while the tray and proxy are still running. A then stopped before files are replaced; an installed service is rebuilt and started automatically, while a foreground installation prints `ocx start` as the next step. Dashboard update records redact profile/cache paths and UID/GID values before they are persisted. + +On Windows, Scoop's default `nodejs` and `nodejs-lts` `npm` installations can be used +from the user home directory; project-local `npm` candidates remain excluded. + If the install step fails, the previous version stays installed and its service is restarted; the terminal output names the next step, and [Update Failed on Windows](/troubleshooting/update-failed/) covers finishing the update and the folders a failed attempt can leave behind. diff --git a/src/update/npm-invocation.mjs b/src/update/npm-invocation.mjs index 0103167dd54..c6b1a50d91a 100644 --- a/src/update/npm-invocation.mjs +++ b/src/update/npm-invocation.mjs @@ -43,6 +43,14 @@ export function resolveNpmCommand( env.USERPROFILE && win32.join(env.USERPROFILE, "scoop", "shims")] .filter(root => typeof root === "string" && win32.isAbsolute(root)); const trustedEntry = entry => trustedRoots.some(root => isInside(root, entry) && !isInside(root, cwd)); + // Scoop installs Node's npm in the app tree, not in scoop/shims. Admit only + // the two Node apps' current npm directories, never arbitrary Scoop apps or + // a launch directory inside the installation itself. + const scoopNodeRoots = typeof env.USERPROFILE === "string" && win32.isAbsolute(env.USERPROFILE) + ? ["nodejs", "nodejs-lts"].map(app => win32.join(env.USERPROFILE, "scoop", "apps", app, "current")) + : []; + const trustedScoopNodeEntry = entry => scoopNodeRoots.some(current => + !isInside(current, cwd) && (isSamePath(current, entry) || isSamePath(win32.join(current, "bin"), entry))); const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") .split(";") .filter(Boolean); @@ -54,7 +62,7 @@ export function resolveNpmCommand( for (const entry of pathEntries) { if (!win32.isAbsolute(entry)) continue; if (isSamePath(entry, cwd)) continue; - if (isInside(cwd, entry) && !trustedEntry(entry)) continue; + if (isInside(cwd, entry) && !trustedEntry(entry) && !trustedScoopNodeEntry(entry)) continue; for (const extension of extensions) { const candidate = win32.join(entry, `npm${extension.toLowerCase()}`); if (exists(candidate)) return win32.resolve(candidate); diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 23c0562120d..70ab7017b2f 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -344,6 +344,11 @@ starts the proxy outside the updater's process tree, so that proxy has to take t held through the repair's health wait, the lease kept it from starting, and recovery fell through to a second, directly started proxy (#5760). The recovery decision is made again after the release. +On Windows, `src/update/npm-invocation.mjs` admits the exact +`%USERPROFILE%\scoop\apps\nodejs{,-lts}\current` and `current\bin` PATH entries +from an ancestor cwd. This exception does not admit other Scoop apps, arbitrary +descendants, or cwd inside that Node installation. + The npm transaction creates each staging directory exclusively and may clean that fresh path while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, because npm's strict script policy plans the global tree before it creates the prefix layout diff --git a/tests/update/update-npm-invocation.test.ts b/tests/update/update-npm-invocation.test.ts index 1f271059bee..76383a9cbaf 100644 --- a/tests/update/update-npm-invocation.test.ts +++ b/tests/update/update-npm-invocation.test.ts @@ -59,6 +59,10 @@ describe("Windows npm update invocation", () => { test.each([ ["C:\\Users\\dev", "C:\\Users\\dev\\AppData\\Local\\Volta\\bin", "LOCALAPPDATA", "C:\\Users\\dev\\AppData\\Local"], ["C:\\", "C:\\Program Files\\nodejs", "ProgramFiles", "C:\\Program Files"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs-lts\\current", "USERPROFILE", "C:\\Users\\dev"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs-lts\\current\\bin", "USERPROFILE", "C:\\Users\\dev"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs\\current", "USERPROFILE", "C:\\Users\\dev"], + ["C:\\Users\\dev", "C:\\Users\\dev\\scoop\\apps\\nodejs\\current\\bin", "USERPROFILE", "C:\\Users\\dev"], ])("resolves trusted npm from broad cwd %s", (cwd, directory, envKey, root) => { const npm = `${directory}\\npm.cmd`; expect(resolveNpmCommand("win32", { PATH: directory, PATHEXT: ".CMD", [envKey]: root }, { @@ -66,6 +70,17 @@ describe("Windows npm update invocation", () => { })).toBe(npm); }); + test("does not trust a Scoop npm inside the launch tree or arbitrary Scoop apps", () => { + const home = "C:\\Users\\dev"; + const root = `${home}\\scoop\\apps\\nodejs-lts\\current`; + const npm = `${root}\\bin\\npm.cmd`; + const env = { PATH: `${root}\\bin`, USERPROFILE: home, PATHEXT: ".CMD" }; + expect(resolveNpmCommand("win32", env, { cwd: root, exists: path => path === npm })).toBeNull(); + expect(resolveNpmCommand("win32", { + ...env, PATH: `${home}\\scoop\\apps\\other\\current\\bin`, + }, { cwd: home, exists: () => true })).toBeNull(); + }); + test("ignores npm candidates in current-directory subtrees", () => { const projectNpm = `${cwd}\\node_modules\\.bin\\npm.cmd`; const env = { From e2f334e83bae27cb974e6f3e5550d63197953fa6 Mon Sep 17 00:00:00 2001 From: kdm1jkm Date: Tue, 29 Sep 2026 21:01:21 +0900 Subject: [PATCH 2/2] fix(update): validate Scoop npm junction boundaries --- .../content/docs/reference/cli/lifecycle.md | 6 +- src/update/npm-invocation.mjs | 36 +++++++++-- structure/ops/service-and-sidecars.md | 11 +++- tests/update/update-npm-invocation.test.ts | 60 ++++++++++++++++++- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 1c0820dbae2..d055b57c2f1 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -829,7 +829,11 @@ while a foreground installation prints `ocx start` as the next step. Dashboard u redact profile/cache paths and UID/GID values before they are persisted. On Windows, Scoop's default `nodejs` and `nodejs-lts` `npm` installations can be used -from the user home directory; project-local `npm` candidates remain excluded. +from the user home directory when their `current` junction stays inside the Node app +directory; the default persistent `bin` directory is also supported. Running inside +that installation (including its resolved version directory or persistent `bin`), +project-local `npm`, `NO_JUNCTION` version-directory entries, and custom Scoop roots +under the home remain excluded. If the install step fails, the previous version stays installed and its service is restarted; the terminal output names the next step, and [Update Failed on Windows](/troubleshooting/update-failed/) diff --git a/src/update/npm-invocation.mjs b/src/update/npm-invocation.mjs index c6b1a50d91a..b2084d9afb7 100644 --- a/src/update/npm-invocation.mjs +++ b/src/update/npm-invocation.mjs @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { win32 } from "node:path"; const CMD_META = /([()%!^"`<>&|;, *?])/g; @@ -38,6 +38,7 @@ export function resolveNpmCommand( ) { if (platform !== "win32") return "npm"; const exists = deps.exists ?? existsSync; + const realpath = deps.realpath ?? realpathSync.native; const cwd = deps.cwd ?? process.cwd(); const trustedRoots = [env.APPDATA, env.LOCALAPPDATA, env.ProgramFiles, env["ProgramFiles(x86)"], env.USERPROFILE && win32.join(env.USERPROFILE, "scoop", "shims")] @@ -47,10 +48,31 @@ export function resolveNpmCommand( // the two Node apps' current npm directories, never arbitrary Scoop apps or // a launch directory inside the installation itself. const scoopNodeRoots = typeof env.USERPROFILE === "string" && win32.isAbsolute(env.USERPROFILE) - ? ["nodejs", "nodejs-lts"].map(app => win32.join(env.USERPROFILE, "scoop", "apps", app, "current")) + ? ["nodejs", "nodejs-lts"].map(app => win32.join(env.USERPROFILE, "scoop", "apps", app)) : []; - const trustedScoopNodeEntry = entry => scoopNodeRoots.some(current => - !isInside(current, cwd) && (isSamePath(current, entry) || isSamePath(win32.join(current, "bin"), entry))); + const trustedScoopNodeEntry = (entry, candidate) => { + const app = scoopNodeRoots.find(root => { + const current = win32.join(root, "current"); + return isSamePath(current, entry) || isSamePath(win32.join(current, "bin"), entry); + }); + if (!app) return null; + try { + const appPath = realpath(app); + const currentPath = realpath(win32.join(app, "current")); + const entryPath = realpath(entry); + const candidatePath = realpath(candidate); + const cwdPath = realpath(cwd); + const persistBin = win32.join(env.USERPROFILE, "scoop", "persist", win32.basename(app), "bin"); + const insideInstall = isInside(currentPath, entryPath); + const persistedBin = isSamePath(entry, win32.join(app, "current", "bin")) + && isSamePath(entryPath, persistBin); + return !isSamePath(appPath, currentPath) && isInside(appPath, currentPath) + && (insideInstall || persistedBin) && isInside(entryPath, candidatePath) + && !isInside(currentPath, cwdPath) && !isInside(entryPath, cwdPath); + } catch { + return false; + } + }; const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") .split(";") .filter(Boolean); @@ -62,10 +84,12 @@ export function resolveNpmCommand( for (const entry of pathEntries) { if (!win32.isAbsolute(entry)) continue; if (isSamePath(entry, cwd)) continue; - if (isInside(cwd, entry) && !trustedEntry(entry) && !trustedScoopNodeEntry(entry)) continue; for (const extension of extensions) { const candidate = win32.join(entry, `npm${extension.toLowerCase()}`); - if (exists(candidate)) return win32.resolve(candidate); + if (!exists(candidate)) continue; + const scoopTrust = trustedScoopNodeEntry(entry, candidate); + if (scoopTrust === false || (scoopTrust === null && isInside(cwd, entry) && !trustedEntry(entry))) continue; + return win32.resolve(candidate); } } return null; diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 70ab7017b2f..f18422ff717 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -344,10 +344,15 @@ starts the proxy outside the updater's process tree, so that proxy has to take t held through the repair's health wait, the lease kept it from starting, and recovery fell through to a second, directly started proxy (#5760). The recovery decision is made again after the release. -On Windows, `src/update/npm-invocation.mjs` admits the exact +On Windows, `src/update/npm-invocation.mjs` admits only the exact `%USERPROFILE%\scoop\apps\nodejs{,-lts}\current` and `current\bin` PATH entries -from an ancestor cwd. This exception does not admit other Scoop apps, arbitrary -descendants, or cwd inside that Node installation. +from outside that Node installation. It resolves the junction, PATH entry, npm candidate, +and cwd to physical paths; `current` must remain within its Scoop app directory and +the npm candidate within the admitted entry. `current\bin` may point to the default +`%USERPROFILE%\scoop\persist\nodejs{,-lts}\bin`; cwd inside that persistent bin +is excluded too. Unreadable paths fail closed. Other Scoop apps, version-directory +PATH entries (`NO_JUNCTION`), custom home-root Scoop installs, arbitrary descendants, +and cwd inside the resolved Node installation are not admitted. The npm transaction creates each staging directory exclusively and may clean that fresh path while the creating process still owns it. On POSIX it also creates the stage's `lib` directory, diff --git a/tests/update/update-npm-invocation.test.ts b/tests/update/update-npm-invocation.test.ts index 76383a9cbaf..f1f5e8c9565 100644 --- a/tests/update/update-npm-invocation.test.ts +++ b/tests/update/update-npm-invocation.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { npmInvocation, resolveNpmCommand, @@ -66,7 +69,7 @@ describe("Windows npm update invocation", () => { ])("resolves trusted npm from broad cwd %s", (cwd, directory, envKey, root) => { const npm = `${directory}\\npm.cmd`; expect(resolveNpmCommand("win32", { PATH: directory, PATHEXT: ".CMD", [envKey]: root }, { - cwd, exists: path => path === npm, + cwd, exists: path => path === npm, realpath: path => path, })).toBe(npm); }); @@ -81,6 +84,61 @@ describe("Windows npm update invocation", () => { }, { cwd: home, exists: () => true })).toBeNull(); }); + test.skipIf(process.platform !== "win32")("resolves real Scoop junctions but rejects resolved cwd and redirected targets", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-scoop-node-")); + try { + const app = join(home, "scoop", "apps", "nodejs-lts"); + const version = join(app, "24.1.0"); + const current = join(app, "current"); + const bin = join(current, "bin"); + mkdirSync(join(version, "bin"), { recursive: true }); + writeFileSync(join(version, "bin", "npm.cmd"), "@echo off\r\n"); + symlinkSync(version, current, "junction"); + const env = { PATH: bin, USERPROFILE: home, PATHEXT: ".CMD", SystemRoot: "C:\\Windows" }; + const npm = join(bin, "npm.cmd"); + + expect(resolveNpmCommand("win32", env, { cwd: home })).toBe(npm); + expect(resolveNpmCommand("win32", env, { cwd: version })).toBeNull(); + expect(npmInvocation(["--version"], "win32", env, { cwd: join(version, "bin") })).toBeNull(); + + rmSync(join(version, "bin"), { recursive: true }); + const persistBin = join(home, "scoop", "persist", "nodejs-lts", "bin"); + mkdirSync(persistBin, { recursive: true }); + writeFileSync(join(persistBin, "npm.cmd"), "@echo off\r\n"); + symlinkSync(persistBin, join(version, "bin"), "junction"); + expect(resolveNpmCommand("win32", env, { cwd: home })).toBe(npm); + expect(resolveNpmCommand("win32", env, { cwd: persistBin })).toBeNull(); + + rmSync(join(version, "bin")); + const outsideBin = join(home, "other-bin"); + mkdirSync(outsideBin); + writeFileSync(join(outsideBin, "npm.cmd"), "@echo off\r\n"); + symlinkSync(outsideBin, join(version, "bin"), "junction"); + expect(resolveNpmCommand("win32", env, { cwd: home })).toBeNull(); + + rmSync(current); + const outside = join(home, "other-app"); + mkdirSync(join(outside, "bin"), { recursive: true }); + writeFileSync(join(outside, "bin", "npm.cmd"), "@echo off\r\n"); + symlinkSync(outside, current, "junction"); + expect(resolveNpmCommand("win32", env, { cwd: home })).toBeNull(); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test("does not admit NO_JUNCTION or a custom Scoop root under the home", () => { + const home = "C:\\Users\\dev"; + for (const entry of [ + `${home}\\scoop\\apps\\nodejs-lts\\24.1.0\\bin`, + `${home}\\custom-scoop\\apps\\nodejs-lts\\current\\bin`, + ]) { + expect(resolveNpmCommand("win32", { PATH: entry, USERPROFILE: home, PATHEXT: ".CMD" }, { + cwd: home, exists: () => true, + })).toBeNull(); + } + }); + test("ignores npm candidates in current-directory subtrees", () => { const projectNpm = `${cwd}\\node_modules\\.bin\\npm.cmd`; const env = {