Merge nucleic/olive-jade-civet-rznt into dev

This commit is contained in:
2026-07-29 00:05:26 -07:00
parent 5af84b8edc
commit b342860779
4 changed files with 529 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
# M1 spikes (docs/WINDOWS_PORT.md §13)
Programs that answer questions the port is currently *guessing* at. They are checked in and
runnable on purpose: a spike whose answer nobody can reproduce six months later is a rumour.
They are deliberately **not** in `windows/Nucleic.sln`. The solution's broker contract tests must
keep running on any machine against `FakeWslc`; these need the `Microsoft.WSL.Containers` preview
NuGet and a real WSL stack, so they are built by path.
---
## `WslcApiDump` — what does the wslc API actually look like?
**The problem it solves.** Items 5, 6 and 7 — the C# broker, the Swift wslc client, and the
control plane — were all built against Microsoft's *documentation*, on machines with no WSL.
`NucleicBroker/Wslc/WslcFacade.cs` is therefore a careful transcription that has never been
compiled against the real assembly, and `-p:UseWslc=true` is the only thing standing between it
and the build. Every line in it might be right. We have no idea which.
A typed spike cannot tell us: if `Session.CreateOrOpen` is really `Session.Open`, the spike fails
to **compile**, which teaches us one name and stops. So this tool is written entirely in
reflection. It cannot fail to build, and a single run prints the complete list of what is wrong.
```powershell
cd windows/spikes/WslcApiDump
dotnet run # dump the API + check every facade assumption
dotnet run -- --probe # + call the two read-only statics (service version, missing components)
dotnet run -- --session # + create a real session and print its live property VALUES
```
It writes the full public object model to `wslc-api-dump.txt` (`--out` to relocate) and prints an
`ok` / `MISSING` line per assumption, each with *why that member matters* and a nearest-name hint.
It exits 0 even when assumptions fail — a mismatch is the product, not an error. Only a genuinely
broken run (the assembly won't load) exits non-zero.
**What to send back:** the console output, and `wslc-api-dump.txt` if anything is MISSING.
### Why `--session` earns its risk
`--session` creates a real wslc session named `nucleic-spike` under
`%LOCALAPPDATA%\Nucleic\spike\wslc` and leaves it running. It exists for one question that type
metadata cannot answer: **where does the WSL-facing host gateway address come from?** §5 makes
gateway TCP the primary control-plane transport, `WslcContainerEngine.ensureRunning` returns that
address to the Swift side, and `control-bridge.js` dials it. The facade guesses
`Session.HostGatewayAddress`. Dumping the live *values* of every session property lets us
recognise a gateway IP whatever it is called — and if nothing on the session looks like one,
that is itself the finding, and §5's hvsocket fallback gets promoted from upside to dependency.
Leaving the session running is also the cheap version of a §2.3 question: broker supervision
assumes wslc state is **service-backed**, so that a crashed `nucleic-brokerd` can reattach and
re-enumerate rather than orphaning containers. If `wslc session ls` still shows `nucleic-spike`
after this process exits, that assumption holds. Tear it down with `wslc` when you're done.
### The three answers that change the design
Most mismatches are a one-line edit in `WslcFacade.cs` — that is exactly what the `IWslc` seam is
for, and nothing above it should move. These three are different:
| Finding | Consequence |
| --- | --- |
| No create-or-attach on `Session` | Broker-crash reattach (§2.3) has no mechanism; supervision needs redesigning before item 11. |
| No gateway address on a live `Session` | §5's primary transport loses its source; promote the AF_HYPERV/AF_VSOCK fallback and spike that instead. |
| No uid on `ProcessSettings` | Recoverable, and already planned for: exec wraps argv in `setpriv`/`su agent -c`. Interceptors and nash don't care about the numeric uid (§3.2). |
---
## Not yet written
- **`WslcSpike`** — the typed happy path: session → GHCR pull of `naros-agent` → container with an
NTFS `ContainerVolume` → `exec git status` in the bind-mounted worktree → stdio round-trip →
SIGTERM, plus the 9P latency numbers §15 wants (`git status` and `npm install` on a real repo,
mounted vs. in-VM). Deliberately held back until `WslcApiDump` has run: written now, against
guessed names, it would not compile, and fixing it blind is the mistake this whole approach
exists to avoid.
- **`HvSocketSpike`** — M1 (b): AF_HYPERV host listener ↔ AF_VSOCK dial from inside a wslc
container, plus gateway-TCP reachability and default-firewall behaviour in NAT and mirrored
modes. Only worth building once a container can be started at all.
+105
View File
@@ -0,0 +1,105 @@
namespace WslcApiDump;
/// <summary>
/// Every member <c>NucleicBroker/Wslc/WslcFacade.cs</c> calls on the preview
/// <c>Microsoft.WSL.Containers</c> API, as plain strings.
///
/// The facade was written from Microsoft's documentation without a machine to run it on
/// (docs/WINDOWS_PORT.md §1.5), so each line here is a *claim* — and this spike's job is to say,
/// for each one, whether the shipped assembly agrees. Strings rather than typed references is the
/// entire trick: a typed spike that names <c>Session.CreateOrOpen</c> fails to COMPILE if that
/// method has a different name, which teaches us nothing. Reflection turns every wrong guess into
/// a printed line instead of a build error, so one run produces the complete worklist.
///
/// Keep in lockstep with WslcFacade.cs. A member that stops being used should leave this list;
/// a newly-used member should join it, so the next run of this tool still covers the real surface.
/// </summary>
internal static class FacadeAssumptions
{
internal sealed record Assumption(string Type, string Member, Kind MemberKind, string Why);
internal enum Kind { Method, Property, Event, Constructor, EnumValue, Type }
internal static readonly Assumption[] All =
[
// ---- Service entry point: onboarding (§8 step 2) ----
new("WslcService", "GetServiceVersion", Kind.Method,
"hello capabilities — hostd degrades across preview→GA churn on this string"),
new("WslcService", "GetMissingComponents", Kind.Method,
"components.missing RPC; drives the guided-install onboarding page"),
new("WslcService", "InstallComponentsAsync", Kind.Method,
"components.install RPC"),
new("ComponentFlags", "None", Kind.EnumValue,
"the 'nothing missing' sentinel the facade filters on"),
// ---- Session: one per channel, hosts every container (§3.2) ----
new("SessionSettings", ".ctor", Kind.Constructor,
"SessionSettings(name, dataDir) — the two-arg shape the facade assumes"),
new("SessionSettings", "CpuCount", Kind.Property, "session.ensure cpu"),
new("SessionSettings", "MemoryMB", Kind.Property,
"session.ensure memoryMB; §3.2 notes a resize needs a sandbox restart"),
new("Session", "CreateOrOpen", Kind.Method,
"THE create-or-attach primitive. If this is absent, broker reattach after a crash "
+ "(§2.3) has no mechanism and the whole supervision design changes"),
new("Session", "Terminate", Kind.Method, "session.terminate RPC"),
new("Session", "SessionTerminationHandler", Kind.Property,
"session.down notification → hostd's reconcile sweep"),
new("Session", "HostGatewayAddress", Kind.Property,
"THE control-plane address (§5). ensureRunning returns it to the Swift engine and "
+ "control-bridge.js dials it. If this member does not exist, §5's primary transport "
+ "needs another source (query the vNIC) or the hvsocket fallback gets promoted"),
// ---- Images ----
new("Session", "PullImageAsync", Kind.Method, "image.pull RPC (naros-agent from GHCR)"),
new("PullImageOptions", ".ctor", Kind.Constructor, "PullImageOptions(reference)"),
new("PullImageOptions", "Credentials", Kind.Property, "GHCR auth (registryAuth)"),
new("PullImageOptions", "Progress", Kind.Event,
"image.pullProgress → the existing controlDownloadProgress UI surface"),
new("RegistryCredentials", ".ctor", Kind.Constructor, "RegistryCredentials(user, password)"),
new("Session", "GetImages", Kind.Method, "image.list / image.inspect"),
new("Session", "DeleteImage", Kind.Method, "image.delete"),
// ---- Containers ----
new("Session", "CreateContainer", Kind.Method, "container.create"),
new("Session", "GetContainers", Kind.Method, "container.list; reattach re-enumeration"),
new("ContainerSettings", ".ctor", Kind.Constructor, "ContainerSettings(image)"),
new("ContainerSettings", "Name", Kind.Property, "channel-suffixed container naming"),
new("ContainerSettings", "Hostname", Kind.Property, "container.create hostname"),
new("ContainerSettings", "NetworkingMode", Kind.Property,
"NAT vs mirrored (§5 item 4) — determines how the guest reaches the host"),
new("ContainerSettings", "Volumes", Kind.Property, "the NTFS worktree bind mount (D8)"),
new("ContainerSettings", "InitProcess", Kind.Property, "naros-init as PID 1 (docs/NAROS.md)"),
new("ContainerVolume", ".ctor", Kind.Constructor,
"ContainerVolume(hostPath, guestPath, readOnly) — the 3-arg shape"),
new("ContainerNetworkingMode", "", Kind.Type, "enum parsed from the RPC's networkingMode"),
new("Container", "Start", Kind.Method, "container.start"),
new("Container", "Stop", Kind.Method, "container.stop(signal, grace)"),
new("Container", "Delete", Kind.Method, "container.delete"),
new("Container", "State", Kind.Property, "container.state → running/stopped/absent"),
new("Container", "GetStatistics", Kind.Method,
"container.stats → ContainerResourceSample; the Swift engine folds deltas from it"),
new("Container", "RunProcess", Kind.Method, "proc.exec — the agent's own exec path"),
new("ContainerState", "Running", Kind.EnumValue, "the one state the facade tests by name"),
new("DeleteContainerFlags", "Force", Kind.EnumValue, "container.delete force"),
// ---- Processes: the agent stdio path (§3.2 WslcProcessHandle) ----
new("ProcessSettings", "CmdLine", Kind.Property, "argv"),
new("ProcessSettings", "Environment", Kind.Property, "env"),
new("ProcessSettings", "WorkingDirectory", Kind.Property, "cwd"),
new("ProcessSettings", "OutputMode", Kind.Property, "event-mode stdio, not polling"),
new("ProcessSettings", "Terminal", Kind.Property, "tty for the Terminal panel (§7)"),
new("ProcessSettings", "UserId", Kind.Property,
"runAsUID 501 (§3.2). If absent, exec falls back to a setpriv/su wrapper argv — "
+ "interceptors and nash don't care about the numeric uid, so this is recoverable"),
new("ProcessSettings", "GroupId", Kind.Property, "runAsGID"),
new("ProcessOutputMode", "Event", Kind.EnumValue,
"the mode that makes stdio push-based; polling would change the whole broker design"),
new("Signal", "", Kind.Type, "the enum Stop/Signal take; the RPC carries POSIX ints"),
new("Process", "OutputReceived", Kind.Event, "→ proc.stdout / proc.stderr notifications"),
new("Process", "Exited", Kind.Event, "→ proc.exit; must never overtake output (OutboundWriter)"),
new("Process", "WriteStdin", Kind.Method, "proc.stdin (NDJSON to the agent)"),
new("Process", "CloseStdin", Kind.Method, "proc.closeStdin"),
new("Process", "Signal", Kind.Method, "proc.signal — the agent's real POSIX Stop path"),
new("Process", "ResizeTerminal", Kind.Method, "proc.resize (tty mode)"),
];
}
+323
View File
@@ -0,0 +1,323 @@
using System.Reflection;
using System.Text;
namespace WslcApiDump;
/// <summary>
/// M1 spike (a), phase 1 (docs/WINDOWS_PORT.md §13): what does Microsoft.WSL.Containers
/// ACTUALLY look like, and where is <c>WslcFacade.cs</c> wrong?
///
/// Everything the Windows container subsystem rests on — items 5, 6 and 7 — was written against
/// documentation, on a machine with no WSL. This program is the cheapest possible way to convert
/// that pile of assumptions into a worklist, and it is written entirely in reflection so that it
/// cannot fail to build no matter how wrong the assumptions turn out to be.
///
/// It never mutates anything unless asked: the default run only reads type metadata. `--probe`
/// additionally calls the two safe statics (service version, missing components). `--session`
/// goes one step further and creates a real session, because the single most important unknown —
/// which property carries the WSL gateway address (§5) — can only be answered by looking at a
/// live one.
/// </summary>
internal static class Program
{
private const string AssemblyName = "Microsoft.WSL.Containers";
private static int Main(string[] args)
{
var probe = args.Contains("--probe") || args.Contains("--session");
var session = args.Contains("--session");
var outPath = ArgValue(args, "--out") ?? "wslc-api-dump.txt";
Assembly assembly;
try
{
assembly = Assembly.Load(new AssemblyName(AssemblyName));
}
catch (Exception e)
{
Console.Error.WriteLine($"could not load {AssemblyName}: {e.Message}");
Console.Error.WriteLine(
"Is the preview NuGet restored? `dotnet restore windows/spikes/WslcApiDump`.");
return 2;
}
var types = assembly.GetExportedTypes().OrderBy(t => t.FullName, StringComparer.Ordinal).ToArray();
Console.WriteLine($"{AssemblyName} {assembly.GetName().Version} — {types.Length} public types");
Console.WriteLine();
var report = new StringBuilder();
report.AppendLine($"# {AssemblyName} {assembly.GetName().Version}");
report.AppendLine($"# location: {assembly.Location}");
report.AppendLine();
foreach (var type in types) DescribeType(type, report);
File.WriteAllText(outPath, report.ToString());
Console.WriteLine($"full API dump → {Path.GetFullPath(outPath)}");
Console.WriteLine();
var missing = CheckAssumptions(types);
if (probe) ProbeStatics(types);
if (session) ProbeSession(types, ArgValue(args, "--session-name") ?? "nucleic-spike");
Console.WriteLine();
Console.WriteLine(missing == 0
? "RESULT: every WslcFacade assumption is present. Fix nothing; write the typed spike."
: $"RESULT: {missing} assumption(s) wrong — each one is a line to change in "
+ "windows/NucleicBroker/Wslc/WslcFacade.cs, and nowhere else (that is what IWslc is for).");
// Exit 0 either way: a mismatch is this tool's PRODUCT, not its failure. Only a genuinely
// broken run (assembly missing) is non-zero, so a wrapper script can tell them apart.
return 0;
}
// MARK: - Type dump
private static void DescribeType(Type type, StringBuilder report)
{
var kind = type.IsEnum ? "enum"
: type.IsInterface ? "interface"
: type.IsValueType ? "struct"
: "class";
report.AppendLine($"{kind} {type.FullName}"
+ (type.BaseType is { } b && b != typeof(object) ? $" : {b.Name}" : ""));
if (type.IsEnum)
{
foreach (var name in Enum.GetNames(type)) report.AppendLine($" .{name}");
report.AppendLine();
return;
}
foreach (var ctor in type.GetConstructors())
report.AppendLine($" .ctor({Parameters(ctor)})");
foreach (var property in type.GetProperties(Public).OrderBy(p => p.Name, StringComparer.Ordinal))
report.AppendLine(
$" {Short(property.PropertyType)} {property.Name} "
+ $"{{ {(property.CanRead ? "get; " : "")}{(property.CanWrite ? "set; " : "")}}}");
foreach (var evt in type.GetEvents(Public).OrderBy(e => e.Name, StringComparer.Ordinal))
report.AppendLine($" event {Short(evt.EventHandlerType)} {evt.Name}");
foreach (var method in type.GetMethods(Public)
.Where(m => !m.IsSpecialName)
.OrderBy(m => m.Name, StringComparer.Ordinal))
report.AppendLine($" {Short(method.ReturnType)} {method.Name}({Parameters(method)})");
report.AppendLine();
}
private const BindingFlags Public =
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
private static string Parameters(MethodBase m) =>
string.Join(", ", m.GetParameters().Select(p => $"{Short(p.ParameterType)} {p.Name}"));
private static string Short(Type? t)
{
if (t is null) return "void";
if (!t.IsGenericType) return t.Name;
var name = t.Name[..t.Name.IndexOf('`')];
return $"{name}<{string.Join(", ", t.GetGenericArguments().Select(Short))}>";
}
// MARK: - Assumption check (the actual product)
private static int CheckAssumptions(Type[] types)
{
Console.WriteLine("WslcFacade.cs assumptions:");
Console.WriteLine();
var wrong = 0;
foreach (var group in FacadeAssumptions.All.GroupBy(a => a.Type))
{
var type = types.FirstOrDefault(t => t.Name == group.Key);
if (type is null)
{
// Summarise rather than repeat: if a whole type is renamed or namespaced away,
// one line naming it beats a paragraph per member. The per-member "why" only
// earns its space when the type exists and a single member is wrong.
wrong += group.Count();
Console.WriteLine($" MISSING TYPE {group.Key} "
+ $"({group.Count()} member(s): {string.Join(", ", group.Select(a => a.Member))})");
var near = Nearest(group.Key, types.Select(t => t.Name));
Console.WriteLine(near.Length > 0
? $" nearest types: {string.Join(", ", near)}"
: " no similarly-named type — check the dump file's namespaces");
continue;
}
foreach (var assumption in group)
{
if (assumption.MemberKind is FacadeAssumptions.Kind.Type || Has(type, assumption))
{
Console.WriteLine($" ok {type.Name}.{assumption.Member}");
continue;
}
wrong++;
Console.WriteLine($" MISSING {type.Name}.{assumption.Member} "
+ $"({assumption.MemberKind.ToString().ToLowerInvariant()})");
Console.WriteLine($" why: {assumption.Why}");
var near = Nearest(assumption.Member, MemberNames(type));
if (near.Length > 0)
Console.WriteLine($" nearest: {string.Join(", ", near)}");
}
}
return wrong;
}
private static bool Has(Type type, FacadeAssumptions.Assumption a) => a.MemberKind switch
{
FacadeAssumptions.Kind.Constructor => type.GetConstructors().Length > 0,
FacadeAssumptions.Kind.EnumValue => type.IsEnum && Enum.GetNames(type).Contains(a.Member),
FacadeAssumptions.Kind.Event => type.GetEvent(a.Member) is not null,
FacadeAssumptions.Kind.Property =>
type.GetProperty(a.Member) is not null || type.GetField(a.Member) is not null,
FacadeAssumptions.Kind.Method => type.GetMethods(Public).Any(m => m.Name == a.Member),
_ => true,
};
private static IEnumerable<string> MemberNames(Type type) =>
type.IsEnum
? Enum.GetNames(type)
: type.GetMembers(Public).Where(m => !m.Name.StartsWith('.')).Select(m => m.Name).Distinct();
/// <summary>Cheap "did they just rename it" hint: shared prefix or containment, no edit
/// distance. A three-name shortlist is enough to spot <c>HostGateway</c> vs
/// <c>HostGatewayAddress</c>, which is the realistic failure mode.</summary>
private static string[] Nearest(string wanted, IEnumerable<string> candidates)
{
var needle = wanted.TrimStart('.');
if (needle.Length == 0) return [];
return candidates
.Where(c => c.Contains(needle, StringComparison.OrdinalIgnoreCase)
|| needle.Contains(c, StringComparison.OrdinalIgnoreCase)
|| SharedPrefix(c, needle) >= 4)
.Distinct()
.Take(3)
.ToArray();
}
private static int SharedPrefix(string a, string b)
{
var n = 0;
while (n < a.Length && n < b.Length && char.ToLowerInvariant(a[n]) == char.ToLowerInvariant(b[n])) n++;
return n;
}
// MARK: - Live probes
/// <summary>The two statics that are safe to call on any machine: they only read state.</summary>
private static void ProbeStatics(Type[] types)
{
Console.WriteLine();
Console.WriteLine("live probe (read-only):");
var service = types.FirstOrDefault(t => t.Name == "WslcService");
if (service is null) { Console.WriteLine(" no WslcService type — skipping"); return; }
foreach (var name in new[] { "GetServiceVersion", "GetMissingComponents" })
{
var method = service.GetMethods(Public)
.FirstOrDefault(m => m.Name == name && m.GetParameters().Length == 0);
if (method is null) { Console.WriteLine($" {name}: absent"); continue; }
try
{
Console.WriteLine($" {name}() = {Render(method.Invoke(null, null))}");
}
catch (TargetInvocationException e)
{
// The interesting failure: WSL not installed. That IS the onboarding condition
// §8 step 2 exists to handle, so report it plainly rather than as a crash.
Console.WriteLine($" {name}() threw {e.InnerException?.GetType().Name}: "
+ $"{e.InnerException?.Message}");
}
}
}
/// <summary>
/// Create a real session and print every readable property of it.
///
/// This is here for one question: §5's control plane needs the WSL-facing host address, the
/// facade guesses it is <c>Session.HostGatewayAddress</c>, and no amount of type-metadata
/// reading tells us whether that property holds what we need. Dumping the live VALUES of a
/// session lets us recognise a gateway IP when we see one, whatever it happens to be called.
///
/// Leaves the session running on purpose — `wslc session ls` should show it, and whether it
/// survives this process exiting is itself one of the §2.3 questions (service-backed state is
/// what makes broker-crash reattach possible). Tear it down with `wslc` when done.
/// </summary>
private static void ProbeSession(Type[] types, string name)
{
Console.WriteLine();
Console.WriteLine($"live probe (creates session '{name}'):");
var settingsType = types.FirstOrDefault(t => t.Name == "SessionSettings");
var sessionType = types.FirstOrDefault(t => t.Name == "Session");
if (settingsType is null || sessionType is null)
{
Console.WriteLine(" SessionSettings/Session absent — skipping");
return;
}
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Nucleic", "spike", "wslc");
Directory.CreateDirectory(dataDir);
object? settings;
try
{
settings = Activator.CreateInstance(settingsType, name, dataDir);
}
catch (Exception e)
{
Console.WriteLine($" SessionSettings(name, dataDir) rejected: {e.InnerException?.Message ?? e.Message}");
Console.WriteLine(" → constructor shape differs; see the .ctor lines in the dump file");
return;
}
var factory = sessionType.GetMethods(Public)
.FirstOrDefault(m => m.IsStatic && m.GetParameters().Length == 1
&& m.GetParameters()[0].ParameterType == settingsType);
if (factory is null)
{
Console.WriteLine(" no static Session factory taking SessionSettings — see the dump");
return;
}
Console.WriteLine($" using {sessionType.Name}.{factory.Name}(SessionSettings)");
object? live;
try
{
live = factory.Invoke(null, [settings]);
}
catch (TargetInvocationException e)
{
Console.WriteLine($" session create threw {e.InnerException?.GetType().Name}: "
+ $"{e.InnerException?.Message}");
return;
}
if (live is null) { Console.WriteLine(" factory returned null"); return; }
Console.WriteLine(" live session properties (look for the gateway address — §5):");
foreach (var property in sessionType.GetProperties(Public)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.OrderBy(p => p.Name, StringComparer.Ordinal))
{
string rendered;
try { rendered = Render(property.GetValue(live)); }
catch (Exception e) { rendered = $"<threw {e.InnerException?.GetType().Name ?? e.GetType().Name}>"; }
Console.WriteLine($" {property.Name} = {rendered}");
}
Console.WriteLine(" (session left running — `wslc session ls`; whether it outlives this "
+ "process is the §2.3 reattach question)");
}
private static string Render(object? value) => value switch
{
null => "null",
string s => $"\"{s}\"",
System.Collections.IEnumerable e and not string =>
"[" + string.Join(", ", e.Cast<object?>().Select(Render)) + "]",
_ => value.ToString() ?? "?",
};
private static string? ArgValue(string[] args, string flag)
{
var i = Array.IndexOf(args, flag);
return i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[i + 1] : null;
}
}
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- M1 spike (a), phase 1 — docs/WINDOWS_PORT.md §13.
Reports the REAL Microsoft.WSL.Containers object model, and checks every assumption
NucleicBroker/Wslc/WslcFacade.cs makes against it.
Deliberately NOT in Nucleic.sln: it needs the preview NuGet, which the broker's own
contract tests must never require (they run on Linux against FakeWslc). Build it by
path — see windows/spikes/README.md. -->
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>wslc-api-dump</AssemblyName>
<RootNamespace>WslcApiDump</RootNamespace>
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- Same pin as the broker (docs/WINDOWS_PORT.md §15). If restore fails because the
version moved, change it HERE first, find out what the new API looks like, and only
then bump the broker — that ordering is the whole point of this spike. -->
<PackageReference Include="Microsoft.WSL.Containers" Version="[0.1.0-preview.1]" />
</ItemGroup>
</Project>