Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,14 +252,40 @@ so that `replace-registry-host` maps the lockfile's canonical npmjs URLs onto th
configured mirror. Machines with no npm configuration are unaffected and continue
to use `registry.npmjs.org`.

Configure a mirror once with:
On Windows, this discovery does not require Node.js or npm to be installed.
The installer preserves an explicit `NPM_CONFIG_GLOBALCONFIG`; otherwise it
checks the system npm's reported global configuration, an explicit
`NPM_CONFIG_PREFIX` (`etc\npmrc` beneath that prefix), or the standard locations:
`%APPDATA%\npm\etc\npmrc`, then `nodejs\etc\npmrc` beneath the native, current,
and x86 Program Files directories. This includes managed configuration placed
in those locations before npm is available on PATH. Existing user `.npmrc`
and npm environment settings retain npm's normal precedence. The installer
does not modify these files.

This reads locally available settings; it does not trigger an Intune/device-policy
sync. On an unconfigured Windows machine, a public-registry network failure can
trigger one Microsoft-specific recovery attempt if local device/account metadata
provides a Microsoft Entra tenant hint. This is only a routing hint, not
authorization. The installer announces the retry, applies the registry only to
the retry process, and preserves lockfile integrity and script approvals. It
never changes saved settings or weakens TLS verification. Explicit npm
configuration, other organizations, and non-network failures do not trigger this
fallback. If recovery is unavailable or fails, the installer stops with guidance
for your organization's approved package-feed provisioning.

If npm is already on PATH, configure a mirror once with:

```sh
npm config set registry <url> --location=global
```

The installers never pin or override the registry themselves; they only make the
machine's existing npm configuration visible to the portable runtime. The
If npm is not installed globally, the Windows installer's dependency-error
message includes a command using the exact path to its downloaded npm. That
command sets the approved registry in your user configuration; substitute the
URL supplied by your organization and follow its feed-authentication instructions.

The installers preserve existing npm configuration. The Windows recovery path
above is limited to an otherwise unconfigured public-registry failure. The
lockfile's integrity hashes are verified whichever registry serves the packages,
so a mirror cannot substitute different content.

Expand Down
5 changes: 4 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ Inspect:

The committed lockfile must use canonical `registry.npmjs.org` resolved URLs.
Microsoft contributors may fetch through a configured internal mirror, but
internal feed URLs must never be committed. Review every new dependency install
internal feed URLs must never be committed to the lockfile or contributor
configuration. The Windows installer's narrowly gated Microsoft recovery endpoint
is an explicitly approved exception in implementation code only; do not copy it
into README examples or installation commands. Review every new dependency install
script before adding an exact-version approval; explicitly deny scripts that
are not required.

Expand Down
104 changes: 104 additions & 0 deletions common/microsoft-signin-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { ExecFileOptionsWithStringEncoding } from "node:child_process";
import { win32 } from "node:path";

// Microsoft's public microsoft.com OpenID metadata, verified 2026-09-15:
// https://login.microsoftonline.com/microsoft.com/v2.0/.well-known/openid-configuration
// This is a routing hint only, never proof of identity, employment, or authorization.
const MICROSOFT_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd011db47";
const MAX_OUTPUT_BYTES = 128 * 1024;
const TIMEOUT_MS = 3_000;

/**
* Parse only dsregcmd's documented device/user state and associated tenant sections.
* https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-device-dsregcmd
* Unknown/localized output deliberately produces no hint.
*/
export function parseMicrosoftSignInHint(output: string): boolean {
if (Buffer.byteLength(output, "utf8") > MAX_OUTPUT_BYTES) return false;

const sections = new Map<string, Map<string, string>>();
let section: Map<string, string> | undefined;
for (const line of output.split(/\r?\n/)) {
const heading = /^\s*\|\s*([^|]+?)\s*\|\s*$/.exec(line);
if (heading) {
const name = heading[1].trim();
section = undefined;
if (!/^(Device State|User State|Tenant Details|Work Account \d+)$/.test(name)) continue;
if (sections.has(name)) return false;
section = new Map();
sections.set(name, section);
continue;
}
if (!section) continue;
const field = /^\s*(AzureAdJoined|WorkplaceJoined|TenantId|WorkplaceTenantId)\s*:\s*(.*?)\s*$/.exec(line);
if (!field) continue;
if (section.has(field[1])) return false;
section.set(field[1], field[2]);
}

const isMicrosoft = (tenant: string | undefined) =>
tenant?.toLowerCase() === MICROSOFT_TENANT_ID;
if (
sections.get("Device State")?.get("AzureAdJoined") === "YES" &&
isMicrosoft(sections.get("Tenant Details")?.get("TenantId"))
) return true;

return sections.get("User State")?.get("WorkplaceJoined") === "YES" &&
[...sections].some(([name, fields]) =>
/^Work Account \d+$/.test(name) && isMicrosoft(fields.get("WorkplaceTenantId")),
);
}

export interface MicrosoftSignInHintDependencies {
platform: string;
systemRoot: string | undefined;
execFile: (
file: string,
args: string[],
options: ExecFileOptionsWithStringEncoding,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => void;
log: { info(message: string, details: { reason: string }): void };
}

/** Injectable probe for tests; it never reads credentials or searches account stores. */
export function createMicrosoftSignInHintProbe(deps: MicrosoftSignInHintDependencies) {
return async (): Promise<boolean> => {
const noHint = (reason: string): false => {
deps.log.info("Microsoft sign-in hint unavailable", { reason });
return false;
};
if (deps.platform !== "win32") return noHint("unsupported-platform");

// Never fall back to PATH, the current directory, or a relative/UNC root.
const root = deps.systemRoot;
if (!root || !/^[a-z]:\\/i.test(root) || /[\x00-\x1f]/.test(root) ||
root.split(/[\\/]/).some((part) => part === "..")) {
return noHint("unsupported-system-path");
}

try {
const output = await new Promise<string>((resolve, reject) => {
deps.execFile(win32.join(root, "System32", "dsregcmd.exe"), ["/status"], {
encoding: "utf8",
timeout: TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
windowsHide: true,
shell: false,
}, (error, stdout) => {
if (error) reject(error);
else resolve(stdout);
});
});
return parseMicrosoftSignInHint(output) || noHint("no-matching-hint");
} catch (error) {
// Whitelist categories only: messages/stdout/stderr can contain account/device data.
const failure = error as { code?: unknown; killed?: unknown } | null;
const reason = failure?.code === "ENOENT" ? "executable-missing"
: failure?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ? "output-limit"
: failure?.code === "ETIMEDOUT" || failure?.killed === true ? "timeout"
: "probe-failed";
return noHint(reason);
}
};
}
107 changes: 4 additions & 103 deletions electron/microsoft-signin-hint.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,9 @@
import { execFile, type ExecFileOptionsWithStringEncoding } from "node:child_process";
import { win32 } from "node:path";
import { execFile } from "node:child_process";
import { createMicrosoftSignInHintProbe } from "../common/microsoft-signin-hint";
import { createLogger } from "./logger";

// Microsoft's public microsoft.com OpenID metadata, verified 2026-09-15:
// https://login.microsoftonline.com/microsoft.com/v2.0/.well-known/openid-configuration
// This is a routing hint only, never proof of identity, employment, or authorization.
const MICROSOFT_TENANT_ID = "72f988bf-86f1-41af-91ab-2d7cd011db47";
const MAX_OUTPUT_BYTES = 128 * 1024;
const TIMEOUT_MS = 3_000;

/**
* Parse only dsregcmd's documented device/user state and associated tenant sections.
* https://learn.microsoft.com/en-us/entra/identity/devices/troubleshoot-device-dsregcmd
* Unknown/localized output deliberately produces no hint.
*/
export function parseMicrosoftSignInHint(output: string): boolean {
if (Buffer.byteLength(output, "utf8") > MAX_OUTPUT_BYTES) return false;

const sections = new Map<string, Map<string, string>>();
let section: Map<string, string> | undefined;
for (const line of output.split(/\r?\n/)) {
const heading = /^\s*\|\s*([^|]+?)\s*\|\s*$/.exec(line);
if (heading) {
const name = heading[1].trim();
section = undefined;
if (!/^(Device State|User State|Tenant Details|Work Account \d+)$/.test(name)) continue;
if (sections.has(name)) return false;
section = new Map();
sections.set(name, section);
continue;
}
if (!section) continue;
const field = /^\s*(AzureAdJoined|WorkplaceJoined|TenantId|WorkplaceTenantId)\s*:\s*(.*?)\s*$/.exec(line);
if (!field) continue;
if (section.has(field[1])) return false;
section.set(field[1], field[2]);
}

const isMicrosoft = (tenant: string | undefined) =>
tenant?.toLowerCase() === MICROSOFT_TENANT_ID;
if (
sections.get("Device State")?.get("AzureAdJoined") === "YES" &&
isMicrosoft(sections.get("Tenant Details")?.get("TenantId"))
) return true;

return sections.get("User State")?.get("WorkplaceJoined") === "YES" &&
[...sections].some(([name, fields]) =>
/^Work Account \d+$/.test(name) && isMicrosoft(fields.get("WorkplaceTenantId")),
);
}

export interface MicrosoftSignInHintDependencies {
platform: string;
systemRoot: string | undefined;
execFile: (
file: string,
args: string[],
options: ExecFileOptionsWithStringEncoding,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => void;
log: Pick<ReturnType<typeof createLogger>, "info">;
}

/** Injectable probe for tests; it never reads credentials or searches account stores. */
export function createMicrosoftSignInHintProbe(deps: MicrosoftSignInHintDependencies) {
return async (): Promise<boolean> => {
const noHint = (reason: string): false => {
deps.log.info("Microsoft sign-in hint unavailable", { reason });
return false;
};
if (deps.platform !== "win32") return noHint("unsupported-platform");

// Never fall back to PATH, the current directory, or a relative/UNC root.
const root = deps.systemRoot;
if (!root || !/^[a-z]:\\/i.test(root) || /[\x00-\x1f]/.test(root) ||
root.split(/[\\/]/).some((part) => part === "..")) {
return noHint("unsupported-system-path");
}

try {
const output = await new Promise<string>((resolve, reject) => {
deps.execFile(win32.join(root, "System32", "dsregcmd.exe"), ["/status"], {
encoding: "utf8",
timeout: TIMEOUT_MS,
maxBuffer: MAX_OUTPUT_BYTES,
windowsHide: true,
shell: false,
}, (error, stdout) => {
if (error) reject(error);
else resolve(stdout);
});
});
return parseMicrosoftSignInHint(output) || noHint("no-matching-hint");
} catch (error) {
// Whitelist categories only: messages/stdout/stderr can contain account/device data.
const failure = error as { code?: unknown; killed?: unknown } | null;
const reason = failure?.code === "ENOENT" ? "executable-missing"
: failure?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ? "output-limit"
: failure?.code === "ETIMEDOUT" || failure?.killed === true ? "timeout"
: "probe-failed";
return noHint(reason);
}
};
}
export { createMicrosoftSignInHintProbe, parseMicrosoftSignInHint } from "../common/microsoft-signin-hint";
export type { MicrosoftSignInHintDependencies } from "../common/microsoft-signin-hint";

export async function hasMicrosoftSignInHint(): Promise<boolean> {
return createMicrosoftSignInHintProbe({
Expand Down
52 changes: 39 additions & 13 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ function Get-SystemNpmGlobalConfigPath {
}

function Get-MachineNpmConfigPath {
# Explicit caller configuration is already understood by portable npm. Never
# replace it, including a missing file that npm itself must handle.
if (-not [string]::IsNullOrWhiteSpace($env:NPM_CONFIG_GLOBALCONFIG)) {
return $null
}

# 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
Expand All @@ -339,13 +345,32 @@ function Get-MachineNpmConfigPath {
if (-not [string]::IsNullOrWhiteSpace($reported)) {
$candidates.Add($reported)
}
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
$candidates.Add((Join-Path $env:APPDATA "npm\etc\npmrc"))
if (-not [string]::IsNullOrWhiteSpace($env:NPM_CONFIG_PREFIX)) {
$candidates.Add((Join-Path $env:NPM_CONFIG_PREFIX "etc\npmrc"))
# An explicit prefix must not inherit another installation's global config.
return Resolve-MachineNpmConfigPath -CandidatePaths $candidates.ToArray()
}

foreach ($candidate in (Get-DefaultWindowsNpmConfigPaths)) {
$candidates.Add($candidate)
}

return Resolve-MachineNpmConfigPath -CandidatePaths $candidates.ToArray()
}

function Get-DefaultWindowsNpmConfigPaths {
# Managed settings may exist at a standard prefix before npm is installed or
# available on PATH. Probe files, not an executable or a corporate endpoint.
if (-not [string]::IsNullOrWhiteSpace($env:APPDATA)) {
Join-Path $env:APPDATA "npm\etc\npmrc"
}
foreach ($programFiles in @($env:ProgramW6432, $env:ProgramFiles, ${env:ProgramFiles(x86)})) {
if (-not [string]::IsNullOrWhiteSpace($programFiles)) {
Join-Path $programFiles "nodejs\etc\npmrc"
}
}
}

function Get-WindowsArchitecture {
$architecture = [Environment]::GetEnvironmentVariable(
"PROCESSOR_ARCHITECTURE",
Expand Down Expand Up @@ -672,6 +697,8 @@ if (Test-Path -LiteralPath $sourceDirectory -PathType Container) {
"package.json",
"package-lock.json",
"scripts\check-lockfile-portability.mjs",
"scripts\install-windows-dependencies.mjs",
"common\microsoft-signin-hint.ts",
"scripts\install-reviewed-electron.mjs",
"scripts\run-reviewed-electron.mjs"
)
Expand Down Expand Up @@ -732,23 +759,22 @@ if (Test-Path -LiteralPath $sourceDirectory -PathType Container) {

try {
Invoke-CheckedCommand `
-FilePath $runtime.Npm `
-FilePath $runtime.Node `
-Arguments @(
"ci",
"--no-audit",
"--no-fund",
"--ignore-scripts=false",
"--dangerously-allow-all-scripts=false",
"--strict-allow-scripts"
"scripts\install-windows-dependencies.mjs",
(Join-Path $runtime.Root "node_modules\npm\bin\npm-cli.js")
) `
-Description "npm ci"
} catch {
$configureRegistry = "& '$($runtime.Npm.Replace("'", "''"))' config set registry '<approved-registry-url>' --location=user"
throw (
"$($_.Exception.Message) Dependencies were requested from $effectiveRegistry. " +
"If your network blocks that registry, configure a compatible mirror for this " +
"machine with 'npm config set registry <url> --location=global' and run the " +
"installer again. The lockfile's integrity hashes are verified whichever " +
"registry serves the packages."
"If your network blocks that registry, complete your organization's package-feed " +
"provisioning and run the installer again. To configure an approved mirror manually, " +
"use the bundled npm (no global npm installation is required): $configureRegistry . " +
"Replace the placeholder with your organization's approved URL and complete any " +
"required feed authentication. The lockfile's integrity hashes are verified " +
"whichever registry serves the packages."
)
}

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading