Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
476a16d
add initial admin mode .md file
nagilson Oct 12, 2026
a11f9e8
WIP - initial admin spec; hand-written.
nagilson Oct 12, 2026
d82c305
cleanup and sync with meeting feedback
nagilson Oct 14, 2026
6b8be92
Futher expansion upon dotnetup admin edge cases or potential issues
nagilson Oct 14, 2026
0467f68
block unsigned dailies in admin mode by default
nagilson Oct 14, 2026
815bed7
consider muxer replacement option
nagilson Oct 14, 2026
6b1a39f
reg key should enforce admin mode rather than user local config file
nagilson Oct 14, 2026
2ca2542
fix admin mode to be able to revert it with uac
nagilson Oct 14, 2026
d393b2a
admin mode shuold set block unsigned by default - ugh claude made thi…
nagilson Oct 14, 2026
7070a6b
copilot fb
nagilson Oct 14, 2026
014af5c
add learnings from meeting with @joeloff
nagilson Oct 14, 2026
1cad672
dotnetup: add admin access mode contracts
nagilson Oct 14, 2026
63b4199
dotnetup: tag telemetry with access mode
nagilson Oct 14, 2026
b20d2f2
dotnetup: block unsupported commands in admin mode
nagilson Oct 14, 2026
c41c672
dotnetup: switch admin mode with init --mode and env set
nagilson Oct 14, 2026
f0b5e27
dotnetup: install Windows installer bundles in admin mode
nagilson Oct 14, 2026
2d1ac81
dotnetup: check admin installs without loading hostfxr in process
nagilson Oct 14, 2026
eea1f45
dotnetup: harden admin installer verification and cancellation
nagilson Oct 14, 2026
3aad05c
dotnetup: keep self update and setup working across admin mode changes
nagilson Oct 14, 2026
16ee132
dotnetup: document admin mode MVP
nagilson Oct 14, 2026
fc43e90
env script and legacy print env script shouldn't cause shell failure
nagilson Oct 16, 2026
604edaa
dotnetup: localize admin mode guidance and simplify gate diagnostics
nagilson Oct 16, 2026
dee539d
dotnetup: hide migration choices while admin mode is selected
nagilson Oct 16, 2026
0edd196
dotnetup: preserve the explicit untracked install option
nagilson Oct 16, 2026
4a0d2a6
dotnetup: remove unused legacy framework branches from policy store
nagilson Oct 16, 2026
887dc28
dotnetup: ignore user data overrides for installer bundle caches
nagilson Oct 16, 2026
00fadfc
dotnetup: consolidate admin test fixtures and command coverage
nagilson Oct 16, 2026
86fea34
Dont use user controllable directory for admin installs if dotnetup i…
nagilson Oct 16, 2026
fa84796
dotnetup: annotate the Authenticode verifier with its supported platform
nagilson Oct 16, 2026
3db2c88
dotnetup: report a specific error for non-.exe installer downloads
nagilson Oct 16, 2026
3e78ae2
dotnetup: check only host/fxr version directories for hostfxr
nagilson Oct 16, 2026
1535a03
dotnetup: localize installer orchestration messages
nagilson Oct 16, 2026
7852f07
dotnetup: clarify how env set leaves admin mode
nagilson Oct 16, 2026
a16c9c4
dotnetup: remove admin installer details from the signature verificat…
nagilson Oct 16, 2026
3754e0a
dotnetup: document the architecture assumption in admin root resolution
nagilson Oct 16, 2026
16a4bcf
dotnetup: share host-reported install checks between validators
nagilson Oct 16, 2026
e1effff
dotnetup: keep the admin-mode --untracked check in the install command
nagilson Oct 16, 2026
05fe8bc
fix style gates
nagilson Oct 19, 2026
44a57ec
Merge branch 'release/dnup' into nagilson-dotnetup-admin-mvp
nagilson Oct 19, 2026
1a94289
dotnetup: inject the machine policy instead of static test hooks
nagilson Oct 17, 2026
415fad1
dotnetup: complete policy injection coverage
nagilson Oct 19, 2026
4bc05bf
dotnetup: remove redundant bundle tracking assertion
nagilson Oct 19, 2026
cb30dcc
Fix dynamic dotnetup test assets after net12 transition
nagilson Oct 19, 2026
a35b6ab
Localize Windows installer bundle messages
nagilson Oct 19, 2026
e2fd7cd
Use native hostfxr invocation without parsing --list-runtime but with…
nagilson Oct 19, 2026
89259ef
Fix CI to not require sdk to build dotnetup to get sdks
nagilson Oct 19, 2026
3bd97cd
Simplify output instructions for admin mode
nagilson Oct 19, 2026
9e93fe3
Cancellation message doesnt display if its on stderr
nagilson Oct 19, 2026
6b9329d
Shorten ctrl + c interrupt + allow switching out with init --mode
nagilson Oct 19, 2026
c167eb9
Isolate installer cancellation rendering tests from CI enrichment
nagilson Oct 19, 2026
7ff5e67
Handle installer-initiated restarts separately
nagilson Oct 22, 2026
f9b2ee2
Store admin mode in per-user configuration
nagilson Oct 22, 2026
863a9a6
Remove access mode selection from dotnetup init
nagilson Oct 22, 2026
2ff6338
Expose safe environment details in admin mode
nagilson Oct 22, 2026
725c346
Document the revised admin mode contract
nagilson Oct 22, 2026
c03195e
secondary scratch directories are not necessary
nagilson Oct 22, 2026
0333810
add non-admin mode selection prompts for dotnetup telemetry dashboard
nagilson Oct 22, 2026
24a8e91
ai agent trying to clean up init and admin mode - but its kind of a …
nagilson Oct 22, 2026
549e2e9
Don't fail if hostfxr API is unavailable + Use DOTNET_DOTNETUP_DATA_D…
nagilson Oct 22, 2026
60410bd
Test when dotnet hostfxr ap is unavailable
nagilson Oct 22, 2026
0138c69
Fix the AI blah text for MSI bundles
nagilson Oct 22, 2026
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
274 changes: 274 additions & 0 deletions documentation/general/dotnetup/designs/dotnetup-admin-mode.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,27 @@ public enum DotnetInstallErrorCode

/// <summary>The user declined confirmation of an operation.</summary>
OperationCancelled,

/// <summary>The invoked command is not supported while dotnetup is in admin mode.</summary>
AdminModeCommandNotSupported,

/// <summary>An option or argument passed to the command is not supported while dotnetup is in admin mode.</summary>
AdminModeSymbolNotSupported,

/// <summary>The operation requires <c>--untracked</c> while dotnetup is in admin mode.</summary>
AdminModeRequiresUntracked,

/// <summary>Admin mode could not be enabled or disabled because elevation was declined or failed, or the policy could not be written.</summary>
AdminModeChangeFailed,

/// <summary>Another Windows Installer (MSI) installation is in progress (exit code 1618).</summary>
InstallerBusy,

/// <summary>The Windows installer bundle returned a failure exit code.</summary>
InstallerFailed,

/// <summary>The Windows installer bundle reported success, but the expected installation could not be verified afterwards.</summary>
InstallerPostInstallVerificationFailed,
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

namespace Microsoft.Dotnet.Installation.Internal;

internal class DotnetArchiveExtractor : IDisposable
internal class DotnetArchiveExtractor : IInstallArtifact
{
private readonly DotnetInstallRequest _request;
private readonly ReleaseVersion _resolvedVersion;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,7 @@ private static string GetExtensionFromUrl(string url)
case FindReleaseFileStatus.NoUserInstallableArtifact:
throw new DotnetInstallException(
DotnetInstallErrorCode.NoUserInstallableArtifact,
string.Format(
System.Globalization.CultureInfo.CurrentCulture,
Strings.NoUserInstallableArtifact,
installRequest.Component,
resolvedVersion),
GetNoUserInstallableArtifactMessage(installRequest, resolvedVersion),
version: resolvedVersion.ToString(),
component: installRequest.Component.ToString());

Expand Down Expand Up @@ -337,6 +333,20 @@ private static string GetExtensionFromUrl(string url)
}
}

private static string GetNoUserInstallableArtifactMessage(DotnetInstallRequest installRequest, ReleaseVersion resolvedVersion)
{
if (installRequest.Options.InstallMechanism == InstallMechanism.WindowsInstallerBundle)
{
return $".NET {installRequest.Component} {resolvedVersion} has no Windows installer for {installRequest.InstallRoot.Architecture}. Choose a different version.";
}

return string.Format(
System.Globalization.CultureInfo.CurrentCulture,
Strings.NoUserInstallableArtifact,
installRequest.Component,
resolvedVersion);
}

private static (string DownloadUrl, string ExpectedHash) BuildEntryFromReleaseFile(
ReleaseFile targetFile,
ReleaseVersion resolvedVersion,
Expand Down Expand Up @@ -417,23 +427,14 @@ private static bool ShouldFallbackToBlobFeed(
string rid = DotnetupUtilities.GetRuntimeIdentifier(installRequest.InstallRoot.Architecture);
var locationsChecked = new List<string>();

// Prefer tar.gz, fall back to zip if unavailable (older .NET versions may not publish tar.gz for Windows)
var tarLocation = BlobFeedUrlBuilder.GetFeedLocation(installRequest.Component, resolvedVersion, rid, ".tar.gz");
locationsChecked.Add(tarLocation.ChecksumUrl);
string? hash = TryGetHashFromUrl(tarLocation.ChecksumUrl, resolvedVersion, installRequest.Component.ToString());
if (hash != null)
foreach (string extension in GetBlobFeedExtensions(installRequest))
{
return (tarLocation.ArchiveUrl, hash);
}

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var zipLocation = BlobFeedUrlBuilder.GetFeedLocation(installRequest.Component, resolvedVersion, rid, ".zip");
locationsChecked.Add(zipLocation.ChecksumUrl);
hash = TryGetHashFromUrl(zipLocation.ChecksumUrl, resolvedVersion, installRequest.Component.ToString());
var location = BlobFeedUrlBuilder.GetFeedLocation(installRequest.Component, resolvedVersion, rid, extension);
locationsChecked.Add(location.ChecksumUrl);
string? hash = TryGetHashFromUrl(location.ChecksumUrl, resolvedVersion, installRequest.Component.ToString());
if (hash != null)
{
return (zipLocation.ArchiveUrl, hash);
return (location.ArchiveUrl, hash);
}
}

Expand All @@ -444,6 +445,26 @@ private static bool ShouldFallbackToBlobFeed(
component: installRequest.Component.ToString());
}

/// <summary>
/// The blob feed file extensions to probe, in order of preference. Installer bundle requests
/// only accept the .exe bundle. Archive requests prefer tar.gz and fall back to zip on Windows
/// (older .NET versions may not publish tar.gz for Windows).
/// </summary>
private static IEnumerable<string> GetBlobFeedExtensions(DotnetInstallRequest installRequest)
{
if (installRequest.Options.InstallMechanism == InstallMechanism.WindowsInstallerBundle)
{
yield return ".exe";
yield break;
}

yield return ".tar.gz";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
yield return ".zip";
}
}

/// <summary>
/// Downloads and parses a .sha512 hash file. Returns null if the URL 404s
/// (the version is not published to the blob feed). Throws for any other error.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,24 @@ internal record InstallRequestOptions()
/// Corresponds to the <c>--verbosity</c> CLI option.
/// </summary>
public Verbosity Verbosity { get; init; }

/// <summary>
/// How the installation is performed. Defaults to <see cref="InstallMechanism.Archive"/>;
/// admin mode requests use <see cref="InstallMechanism.WindowsInstallerBundle"/>.
/// </summary>
public InstallMechanism InstallMechanism { get; init; } = InstallMechanism.Archive;
}

/// <summary>
/// The mechanism used to lay down a .NET installation.
/// </summary>
internal enum InstallMechanism
{
/// <summary>Download an archive and extract it into the install root.</summary>
Archive = 0,

/// <summary>Run the Windows installer bundle (.exe) into the shared system folder.</summary>
WindowsInstallerBundle = 1,
}

/// <summary>
Expand Down
129 changes: 129 additions & 0 deletions src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetupPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Runtime.Versioning;
#if NET
using Microsoft.Win32;
#endif

namespace Microsoft.Dotnet.Installation.Internal;

/// <summary>
/// Reads and writes the machine-wide dotnetup policy flags.
/// </summary>
internal interface IDotnetupPolicyStore
{
/// <summary>
/// Returns true when the named flag is set to a nonzero value. Returns false when the value is
/// missing, unreadable, or not a DWORD.
/// </summary>
bool ReadFlag(string valueName);

/// <summary>
/// Writes each flag as a DWORD 1 (true) or 0 (false), creating the policy key if needed.
/// Throws when the values cannot be written.
/// </summary>
void WriteFlags(IReadOnlyDictionary<string, bool> values);
}

/// <summary>
/// Machine-wide dotnetup policy stored as independent <c>REG_DWORD</c> values under
/// <c>HKLM\SOFTWARE\Policies\Microsoft\dotnet\Dotnetup</c>. Admin mode is Windows-only and is only
/// ever read from this policy store, never from the per-user dotnetup configuration.
/// </summary>
internal static class DotnetupPolicy
{
internal const string WindowsPolicyKey = @"SOFTWARE\Policies\Microsoft\dotnet\Dotnetup";
internal const string AdminModeValueName = "AdminMode";
internal const string BlockUnsignedDownloadsValueName = "BlockUnsignedDownloads";
Comment thread
nagilson marked this conversation as resolved.

/// <summary>
/// The store backing the policy. Defaults to the Windows registry; tests may replace it and must
/// call <see cref="ResetStoreForTesting"/> afterwards.
/// </summary>
internal static IDotnetupPolicyStore Store { get; set; } = CreateDefaultStore();

internal static void ResetStoreForTesting() => Store = CreateDefaultStore();
Comment thread
nagilson marked this conversation as resolved.
Outdated

/// <summary>Returns true when the machine policy enables admin mode. Always false off Windows.</summary>
public static bool IsAdminModeEnabled() =>
OperatingSystem.IsWindows() && Store.ReadFlag(AdminModeValueName);

/// <summary>Returns true when the Windows registry policy blocks unsigned downloads.</summary>
public static bool IsUnsignedDownloadBlockedByRegistry() =>
Store.ReadFlag(BlockUnsignedDownloadsValueName);

/// <summary>Enables admin mode, which also blocks unsigned downloads.</summary>
public static void EnableAdminMode() =>
Store.WriteFlags(new Dictionary<string, bool>
{
[AdminModeValueName] = true,
[BlockUnsignedDownloadsValueName] = true,
});

/// <summary>
/// Disables admin mode. The unsigned download policy is left unchanged because IT may manage it
/// independently.
/// </summary>
public static void DisableAdminMode() =>
Store.WriteFlags(new Dictionary<string, bool>
{
[AdminModeValueName] = false,
});

private static IDotnetupPolicyStore CreateDefaultStore() =>
OperatingSystem.IsWindows() ? new WindowsRegistryPolicyStore() : new UnsupportedPlatformPolicyStore();

[SupportedOSPlatform("windows")]
private sealed class WindowsRegistryPolicyStore : IDotnetupPolicyStore
Comment thread
nagilson marked this conversation as resolved.
Outdated
{
public bool ReadFlag(string valueName)
{
#if NET
try
{
using RegistryKey baseKey = OpenLocalMachine();
using RegistryKey? key = baseKey.OpenSubKey(WindowsPolicyKey);
return key?.GetValue(valueName) is int value && value != 0;
}
catch
{
return false;
}
#else
return false;
#endif
}

public void WriteFlags(IReadOnlyDictionary<string, bool> values)
{
ArgumentNullException.ThrowIfNull(values);
#if NET
Comment thread
nagilson marked this conversation as resolved.
Outdated
using RegistryKey baseKey = OpenLocalMachine();
using RegistryKey key = baseKey.CreateSubKey(WindowsPolicyKey, writable: true)
?? throw new InvalidOperationException($@"Unable to open HKLM\{WindowsPolicyKey} for writing.");
foreach (var (valueName, enabled) in values)
{
key.SetValue(valueName, enabled ? 1 : 0, RegistryValueKind.DWord);
}
#else
throw new PlatformNotSupportedException();
#endif
}

#if NET
private static RegistryKey OpenLocalMachine() =>
RegistryKey.OpenBaseKey(
RegistryHive.LocalMachine,
Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Registry32);
#endif
}

private sealed class UnsupportedPlatformPolicyStore : IDotnetupPolicyStore
{
public bool ReadFlag(string valueName) => false;

public void WriteFlags(IReadOnlyDictionary<string, bool> values) =>
throw new PlatformNotSupportedException("dotnetup machine policy is only supported on Windows.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Dotnet.Installation.Internal;

/// <summary>
/// A downloadable unit of installation. <see cref="Prepare"/> acquires the artifact (downloads may run
/// concurrently) and <see cref="Commit"/> lays it down on the machine (commits run sequentially).
/// </summary>
internal interface IInstallArtifact : IDisposable
{
/// <summary>Downloads and verifies the artifact without modifying the installation.</summary>
void Prepare();

/// <summary>Installs the prepared artifact. <see cref="Prepare"/> must have completed first.</summary>
void Commit();

/// <summary>The subcomponents laid down by the last <see cref="Commit"/> call, for manifest tracking.</summary>
IReadOnlyList<string> ExtractedSubcomponents { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,9 @@ internal static bool IsMatchingRuntimeComponent(string componentName, InstallCom
/// classifies the result. When no user-installable archive exists, distinguishes between
/// a platform that has no files at all (<see cref="FindReleaseFileStatus.NoMatchingFile"/>)
/// and one that publishes only non-installable artifacts such as .exe installers
/// (<see cref="FindReleaseFileStatus.NoUserInstallableArtifact"/>).
/// (<see cref="FindReleaseFileStatus.NoUserInstallableArtifact"/>). For
/// <see cref="InstallMechanism.WindowsInstallerBundle"/> requests the component's .exe installer
/// bundle is selected instead of an archive.
/// </summary>
/// <remarks>
/// Some components (notably ASP.NET Core) include both regular and composite (AOT) archives
Expand All @@ -381,7 +383,9 @@ internal static FindReleaseFileResult ResolveReleaseFile(ReleaseComponent releas
.Where(f => !IsApphostPackArchive(f.Name))
.ToList();

var match = SelectInstallableArchive(ridFiles);
var match = installRequest.Options.InstallMechanism == InstallMechanism.WindowsInstallerBundle
? SelectInstallerBundle(ridFiles, installRequest.Component)
: SelectInstallableArchive(ridFiles);
if (match is not null)
{
return FindReleaseFileResult.FromFile(match);
Expand Down Expand Up @@ -415,6 +419,32 @@ internal static FindReleaseFileResult ResolveReleaseFile(ReleaseComponent releas
return null;
}

/// <summary>
/// Selects the component's Windows installer bundle (.exe). Bundles are Windows-only, so
/// returns <c>null</c> on other platforms.
/// </summary>
private static ReleaseFile? SelectInstallerBundle(IReadOnlyList<ReleaseFile> ridFiles, InstallComponent component)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}

return ridFiles.FirstOrDefault(f => IsInstallerBundleFor(f.Name, component));
}

/// <summary>
/// Determines whether a release file name is the Windows installer bundle for the component,
/// e.g. <c>dotnet-sdk-win-x64.exe</c>. Requiring the component's own archive prefix excludes
/// other installers listed alongside it, such as the ASP.NET Core hosting bundle
/// (<c>dotnet-hosting-win.exe</c>).
/// </summary>
internal static bool IsInstallerBundleFor(string fileName, InstallComponent component)
{
return fileName.StartsWith(BlobFeedUrlBuilder.GetArchivePrefix(component) + "-", StringComparison.OrdinalIgnoreCase)
&& fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Classifies a release that has no user-installable archive for the platform. Called only
/// after <see cref="SelectInstallableArchive"/> returns <c>null</c>. When the release still
Expand Down
Loading