From 51e3ab2c5649f3240d705fae37ac848443ca3967 Mon Sep 17 00:00:00 2001 From: Giorgio Ughini Date: Fri, 7 Aug 2026 13:33:31 +0200 Subject: [PATCH] fix(install): honour the machine npm registry in the portable runtime The installers unpack a portable Node.js archive and run its bundled npm. Node.js ships the builtin npmrc that sets `prefix=${APPDATA}\npm` only in the Windows MSI, so the portable archive leaves npm resolving `globalconfig` inside the throwaway runtime directory. On machines whose npm registry is configured in the global npmrc rather than a user `~/.npmrc`, npm never saw that file, fell back to registry.npmjs.org, and `npm ci` failed with ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE wherever network policy blocks the public registry. Locate the machine's real global npmrc before the portable runtime is prepended to PATH and pass it to npm as NPM_CONFIG_GLOBALCONFIG. npm's default `replace-registry-host=npmjs` then maps the lockfile's canonical npmjs URLs onto the configured mirror, and the lockfile integrity hashes are still verified. Machines without any npm configuration resolve no override and keep using registry.npmjs.org exactly as before. Also add a SKILL_RECORDER_NPM_REGISTRY escape hatch, report the registry that will actually be used, and explain the mirror options when `npm ci` fails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- INSTALL.md | 22 +++++ install.ps1 | 135 ++++++++++++++++++++++++++++--- install.sh | 48 ++++++++++- scripts/install-windows.test.ps1 | 37 ++++++++- 4 files changed, 229 insertions(+), 13 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 4729c58..1561192 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -241,6 +241,28 @@ corporate registry. Use `npm ci`, not `npm install`, and do not regenerate the lockfile during installation. Keep the entire checkout, `.compliance`, and dependency legal files. +### Networks that block registry.npmjs.org + +The installers run a portable Node.js runtime that is unpacked into the +installation root. Because Node.js's portable archives ship no builtin npmrc, +npm would resolve its global configuration inside that throwaway runtime +directory and ignore the registry configured for the machine. The installers +therefore locate the machine's real global npmrc and pass it to the portable npm +so that `replace-registry-host` maps the lockfile's canonical npmjs URLs onto the +configured mirror. Machines with no npm configuration are unaffected and continue +to use `registry.npmjs.org`. + +Configure a mirror once with: + +```sh +npm config set registry --location=global +``` + +To point a single installation at a specific registry without changing any npm +configuration, set `SKILL_RECORDER_NPM_REGISTRY` to an HTTPS registry URL before +running the installer. The lockfile's integrity hashes are verified whichever +registry serves the packages, so a mirror cannot substitute different content. + ## Licensing boundary The source channels distribute this repository's MIT-licensed source. The diff --git a/install.ps1 b/install.ps1 index 47d70b5..98daf58 100644 --- a/install.ps1 +++ b/install.ps1 @@ -276,6 +276,76 @@ function Invoke-CheckedCommand { } } +function Resolve-MachineNpmConfigPath { + param([string[]]$CandidatePaths) + + foreach ($candidate in $CandidatePaths) { + if ([string]::IsNullOrWhiteSpace($candidate)) { + continue + } + $trimmed = $candidate.Trim().Trim('"') + if ($trimmed -in @("undefined", "null")) { + continue + } + # A malformed npm configuration must never block a portable installation. + try { + if (Test-Path -LiteralPath $trimmed -PathType Leaf) { + return [IO.Path]::GetFullPath($trimmed) + } + } catch { + continue + } + } + return $null +} + +function Get-SystemNpmGlobalConfigPath { + $npmCommand = @( + Get-Command npm -CommandType Application -ErrorAction SilentlyContinue + ) | Select-Object -First 1 + if (-not $npmCommand) { + return $null + } + + $previousPreference = $ErrorActionPreference + $output = @() + $exitCode = 1 + try { + $ErrorActionPreference = "Continue" + $output = @(& $npmCommand.Source "config" "get" "globalconfig" 2>$null) + $exitCode = $LASTEXITCODE + } catch { + $output = @() + $exitCode = 1 + } finally { + $ErrorActionPreference = $previousPreference + $global:LASTEXITCODE = 0 + } + + if ($exitCode -ne 0 -or $output.Count -eq 0) { + return $null + } + return ([string]$output[-1]).Trim() +} + +function Get-MachineNpmConfigPath { + # The portable Node.js archive ships no builtin npmrc, so npm resolves its + # global config inside the throwaway runtime directory and silently ignores a + # registry configured for this machine. Point npm back at the real file so + # networks that block registry.npmjs.org still install through their mirror. + $candidates = New-Object System.Collections.Generic.List[string] + + $reported = Get-SystemNpmGlobalConfigPath + if (-not [string]::IsNullOrWhiteSpace($reported)) { + $candidates.Add($reported) + } + if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) { + $candidates.Add((Join-Path $env:APPDATA "npm\etc\npmrc")) + } + + return Resolve-MachineNpmConfigPath -CandidatePaths $candidates.ToArray() +} + function Get-WindowsArchitecture { $architecture = [Environment]::GetEnvironmentVariable( "PROCESSOR_ARCHITECTURE", @@ -606,10 +676,35 @@ if (Test-Path -LiteralPath $sourceDirectory -PathType Container) { "scripts\run-reviewed-electron.mjs" ) + $registryOverride = $env:SKILL_RECORDER_NPM_REGISTRY + if (-not [string]::IsNullOrWhiteSpace($registryOverride)) { + $registryOverride = $registryOverride.Trim() + $parsedRegistry = $null + if ( + -not [Uri]::TryCreate($registryOverride, [UriKind]::Absolute, [ref]$parsedRegistry) -or + $parsedRegistry.Scheme -ne "https" + ) { + throw "SKILL_RECORDER_NPM_REGISTRY must be an absolute HTTPS URL: $registryOverride" + } + } else { + $registryOverride = $null + } + $environmentOverrides = [ordered]@{ PATH = "$($runtime.Root);$env:PATH" NPM_CONFIG_ALLOW_SCRIPTS = $null } + if ($registryOverride) { + Write-Step "Using the npm registry requested by SKILL_RECORDER_NPM_REGISTRY." + $environmentOverrides["NPM_CONFIG_REGISTRY"] = $registryOverride + } else { + $machineNpmConfig = Get-MachineNpmConfigPath + if ($machineNpmConfig) { + Write-Step "Applying this machine's npm configuration from $machineNpmConfig." + $environmentOverrides["NPM_CONFIG_GLOBALCONFIG"] = $machineNpmConfig + } + } + $originalEnvironment = @{} foreach ($entry in $environmentOverrides.GetEnumerator()) { $originalEnvironment[$entry.Key] = [Environment]::GetEnvironmentVariable( @@ -642,17 +737,35 @@ if (Test-Path -LiteralPath $sourceDirectory -PathType Container) { -Description "lockfile portability validation" Write-Step "Installing lockfile-pinned dependencies through the configured npm registry." - Invoke-CheckedCommand ` - -FilePath $runtime.Npm ` - -Arguments @( - "ci", - "--no-audit", - "--no-fund", - "--ignore-scripts=false", - "--dangerously-allow-all-scripts=false", - "--strict-allow-scripts" - ) ` - -Description "npm ci" + $registryOutput = @(& $runtime.Npm config get registry) + $effectiveRegistry = "the configured npm registry" + if ($LASTEXITCODE -eq 0 -and $registryOutput.Count -gt 0) { + $effectiveRegistry = ([string]$registryOutput[-1]).Trim() + Write-Step "Dependencies will be downloaded from $effectiveRegistry." + } + $global:LASTEXITCODE = 0 + + try { + Invoke-CheckedCommand ` + -FilePath $runtime.Npm ` + -Arguments @( + "ci", + "--no-audit", + "--no-fund", + "--ignore-scripts=false", + "--dangerously-allow-all-scripts=false", + "--strict-allow-scripts" + ) ` + -Description "npm ci" + } catch { + throw ( + "$($_.Exception.Message) Dependencies were requested from $effectiveRegistry. " + + "If your network blocks that registry, configure a compatible mirror with " + + "'npm config set registry --location=global', or set " + + "SKILL_RECORDER_NPM_REGISTRY= before running the installer again. " + + "The lockfile's integrity hashes are verified whichever registry serves the packages." + ) + } $electronDistribution = Assert-ReviewedElectronDistribution ` -SourceDirectory $buildDirectory ` diff --git a/install.sh b/install.sh index 4390cc8..eb54500 100755 --- a/install.sh +++ b/install.sh @@ -108,6 +108,22 @@ checksum_from_manifest() { awk -v name="$file_name" '$2 == name || $2 == "*" name { print tolower($1); exit }' "$manifest" } +detect_machine_npm_config() { + # The portable Node.js archive ships no builtin npmrc, so npm resolves its + # global config inside the throwaway runtime directory and silently ignores a + # registry configured for this machine. Capture the real path before the + # portable runtime is prepended to PATH so mirrored registries keep working. + local candidate="" + if have npm; then + candidate="$(npm config get globalconfig 2>/dev/null | tail -n 1 | tr -d '\r')" || candidate="" + fi + case "$candidate" in + ""|undefined|null) return 0 ;; + esac + [ -f "$candidate" ] || return 0 + printf '%s' "$candidate" +} + install_node_runtime() { local channel="https://nodejs.org/dist/latest-v24.x" local sums="$WORK_DIR/node-SHASUMS256.txt" @@ -266,18 +282,46 @@ build_source_install() { export NPM_CONFIG_CACHE="$INSTALL_ROOT/npm-cache" unset NPM_CONFIG_ALLOW_SCRIPTS npm_config_allow_scripts + if [ -n "${SKILL_RECORDER_NPM_REGISTRY:-}" ]; then + case "$SKILL_RECORDER_NPM_REGISTRY" in + https://*) ;; + *) + die "SKILL_RECORDER_NPM_REGISTRY must be an absolute HTTPS URL: $SKILL_RECORDER_NPM_REGISTRY." + ;; + esac + info "Using the npm registry requested by SKILL_RECORDER_NPM_REGISTRY." + export NPM_CONFIG_REGISTRY="$SKILL_RECORDER_NPM_REGISTRY" + elif [ -n "${MACHINE_NPM_CONFIG:-}" ]; then + info "Applying this machine's npm configuration from $MACHINE_NPM_CONFIG." + export NPM_CONFIG_GLOBALCONFIG="$MACHINE_NPM_CONFIG" + fi + info "Validating portable dependency policy." local npm_version npm_version="$("$NPM" --version)" || die "Could not determine the bundled npm version." "$NODE" "scripts/check-lockfile-portability.mjs" --npm-version "$npm_version" info "Installing lockfile-pinned dependencies through the configured npm registry." + local effective_registry + effective_registry="$("$NPM" config get registry 2>/dev/null | tail -n 1 | tr -d '\r')" || + effective_registry="" + [ -n "$effective_registry" ] || effective_registry="the configured npm registry" + info "Dependencies will be downloaded from $effective_registry." + "$NPM" ci \ --no-audit \ --no-fund \ --ignore-scripts=false \ --dangerously-allow-all-scripts=false \ - --strict-allow-scripts + --strict-allow-scripts || + die "$( + printf '%s' \ + "npm ci failed. Dependencies were requested from $effective_registry. " \ + "If your network blocks that registry, configure a compatible mirror with " \ + "'npm config set registry --location=global', or set " \ + "SKILL_RECORDER_NPM_REGISTRY= before running the installer again. " \ + "The lockfile's integrity hashes are verified whichever registry serves the packages." + )" local policy_key="$PLATFORM-$ARCHITECTURE" local electron_version reviewed_hash @@ -470,6 +514,8 @@ write_launcher() { fi } +MACHINE_NPM_CONFIG="$(detect_machine_npm_config)" + install_node_runtime SOURCE_DIR="$VERSIONS_ROOT/$COMMIT" diff --git a/scripts/install-windows.test.ps1 b/scripts/install-windows.test.ps1 index d156dcc..de7f053 100644 --- a/scripts/install-windows.test.ps1 +++ b/scripts/install-windows.test.ps1 @@ -23,7 +23,8 @@ if ($parseErrors.Count -ne 0) { $helperNames = @( "ConvertTo-ExtendedLengthPath", "Move-DirectoryTree", - "Remove-DirectoryTree" + "Remove-DirectoryTree", + "Resolve-MachineNpmConfigPath" ) $functionDefinitions = @( $installerAst.FindAll( @@ -50,6 +51,40 @@ if ($uncPath -ne "\\?\UNC\server\share\folder") { throw "Extended-length UNC conversion returned an unexpected path: $uncPath" } +$npmConfigRoot = Join-Path ( + [IO.Path]::GetTempPath() +) ("skill-recorder-npmrc-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $npmConfigRoot -Force | Out-Null +try { + $machineNpmrc = Join-Path $npmConfigRoot "npmrc" + Set-Content -LiteralPath $machineNpmrc -Value "registry=https://example.invalid/npm/" -Encoding ASCII + $missingNpmrc = Join-Path $npmConfigRoot "missing\npmrc" + + $resolved = Resolve-MachineNpmConfigPath -CandidatePaths @($missingNpmrc, $machineNpmrc) + if ($resolved -ne [IO.Path]::GetFullPath($machineNpmrc)) { + throw "Resolve-MachineNpmConfigPath skipped the existing npmrc: $resolved" + } + + # npm prints "undefined" when no global config is configured; it is not a path. + $placeholders = Resolve-MachineNpmConfigPath -CandidatePaths @("undefined", "null", "", $null) + if ($null -ne $placeholders) { + throw "Resolve-MachineNpmConfigPath accepted a placeholder value: $placeholders" + } + + # Installs on machines without any npm configuration must stay on the default registry. + $absent = Resolve-MachineNpmConfigPath -CandidatePaths @($missingNpmrc) + if ($null -ne $absent) { + throw "Resolve-MachineNpmConfigPath returned a nonexistent npmrc: $absent" + } + + $quoted = Resolve-MachineNpmConfigPath -CandidatePaths @(('"' + $machineNpmrc + '" ')) + if ($quoted -ne [IO.Path]::GetFullPath($machineNpmrc)) { + throw "Resolve-MachineNpmConfigPath did not normalize a quoted npm path: $quoted" + } +} finally { + Remove-Item -LiteralPath $npmConfigRoot -Recurse -Force +} + $testRoot = Join-Path ( [IO.Path]::GetTempPath() ) ("skill-recorder-installer-" + [guid]::NewGuid().ToString("N"))