diff --git a/spikes/README.md b/spikes/README.md
index ea485eb..2874d0b 100644
--- a/spikes/README.md
+++ b/spikes/README.md
@@ -36,6 +36,8 @@ dotnet run -- --probe # + GetMissingComponents / GetVersion
dotnet run -- --session # + create a session, a SECOND with the same name, identity-test, tear down
dotnet run -- --session --keep # …and leave the sessions running afterwards
dotnet run -- --all-types # include the ABI/marshalling plumbing in the dump
+dotnet run -- --internal # can the SERVICE-INTERNAL COM interface be reached? (QI only)
+dotnet run -- --internal-call # …and call through it (can crash — that is the finding)
```
**If it reports missing components**, the machine cannot run wslc yet, and the two components are
@@ -109,19 +111,40 @@ told you to clean it up with a command that doesn't exist. Pass `--keep` to leav
how you check the other half of the §2.3 question: whether session state outlives the process that
created it. With `--keep`, `wsl --shutdown` clears everything.
-The gateway address is *not* what this probe is for any more — §13.1 established that no API
-surfaces one, and D13 moves the control plane to hvsocket via `IWSLCVirtualMachine::GetId`.
+The gateway address is *not* what this probe is for — §13.1 established that no API surfaces one,
+so it comes from `GetAdaptersAddresses` over `vEthernet (WSL)` instead, which `WslcFacade` now does.
-### The three answers that change the design
+### `--internal` — is D13's internal arm even reachable?
+
+The newest open question, and the one item 5 stopped at (docs/WINDOWS_PORT.md §13.2). D13 routes
+enumeration, reattach and the Terminal panel's pty to the service-internal `IWSLCSessionManager`
+(IID `82A7ABC8-…`). But **`wslc.idl` declares interfaces and no activatable class** — there is no
+CLSID in it, so `CoCreateInstance` has nothing to name. The only registered coclasses in WSL's
+IDLs belong to the compat surface (`WSLCCompatSessionManager` and its factory) and to the WSL
+service proper (`LxssUserSession`, `LxssUserSessionInBox`).
+
+The likely answer is that one of those objects also implements the internal interface — COM
+objects routinely expose several — so `--internal` activates each and QIs for the internal IIDs.
+It **never calls a method**, so a vtable mismatch cannot crash it, and QI alone answers the
+question. `--internal-call` then calls `GetVersion` (slot 3) and `ListSessions` to confirm the
+vtable really matches the IDL; *that* can take the process down, which is why it is opt-in.
+
+It also QIs `IWSLCVirtualMachine`, which is **expected to fail**: per the IDL only
+`IWSLCVirtualMachineFactory::CreateVirtualMachine` produces one, and the SYSTEM service owns the
+factory. §13.1's hvsocket-primary decision was retracted on that reading, and a decision reversed
+by reading deserves confirming against the machine.
+
+### The 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 container enumeration, stats, pty or attach in the SDK | **Settled by D13.** `wslcsdk.dll` wraps `WSLCCompat.idl` (the stable SDK surface), which genuinely lacks them; they all exist on `wslc.idl`, the service-internal COM interface `wslc.exe` calls — `ListContainers`, `Stats`, `ResizeTty`, `OpenContainer`/`Attach`, `OpenSessionByName`, and `IWSLCVirtualMachine::GetId`. The broker binds both surfaces; no CLI. |
-| No create-or-attach on `Session` | **Answered:** `IWSLCSessionManager::OpenSessionByName` / `EnterSession` / `ListSessions` on the internal interface. §2.3 reattach has its mechanism. |
-| No gateway address anywhere on the API | **Skip TCP:** `IWSLCVirtualMachine::GetId` returns the VM GUID, so §5's AF_HYPERV/AF_VSOCK path (true vsock parity with macOS) should become primary at M1 (b). Gateway TCP stays as fallback, its address from `GetAdaptersAddresses` over `vEthernet (WSL)`. |
+| No container enumeration, stats, pty or attach in the SDK | **D13's answer, now gated.** They exist on `wslc.idl`, the service-internal interface `wslc.exe` calls — but that IDL declares no coclass, so reaching it at all is what `--internal` tests. Stats no longer wait on it: `WslcFacade` reads cgroup v2 in the guest instead. |
+| `Container` has no `Name`, `Session` has no `GetContainers()` | Sharper than "no enumeration": a container is reachable ONLY through the handle `CreateContainer` returned, so the broker keeps its own name→handle roster — which dies with the process. A restarted broker sees an empty sandbox (§13.2). |
+| No create-or-attach on `Session` | `IWSLCSessionManager::OpenSessionByName` / `EnterSession` — same gate as above. Until then `session.ensure` fails `session_exists` and names `wsl --shutdown` as the remedy. |
+| No gateway address anywhere on the API | **Gateway TCP stays primary.** The hvsocket alternative needed a VMID from `IWSLCVirtualMachine::GetId`, and that interface is unreachable from a client — retracted in §13.1. The address comes from `GetAdaptersAddresses` over `vEthernet (WSL)`. |
| 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). |
---
@@ -134,9 +157,11 @@ for, and nothing above it should move. These three are different:
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), and now the *primary* control-plane spike rather than the
- fallback one (D13): bind an AF_HYPERV listener on the VM GUID from
- `IWSLCVirtualMachine::GetId` and dial AF_VSOCK from inside a wslc container. If vsock
- traverses the container's namespaces, the Windows control plane gets true parity with macOS
- and §5's firewall/NAT variance stops mattering. Measure gateway-TCP reachability in the same
- run so the fallback stays evidenced.
+- **`GatewaySpike`** — M1 (b), and the control-plane spike that actually matters now: can a
+ `Bridged` wslc container reach the host at the `vEthernet (WSL)` address `WslcFacade` returns,
+ under the **default** Windows firewall, and does `control-bridge.js` complete an MCP round trip
+ over it? This is the committed path, not a fallback.
+- **`HvSocketSpike`** — downgraded back to upside (§13.2). It needs a VM GUID to bind `AF_HYPERV`
+ to, and `IWSLCVirtualMachine::GetId` turned out to be unreachable from a client, so the VMID
+ would have to come from outside wslc entirely — HCS enumeration, or the WSL VM's registry
+ identity. Worth doing only if the gateway path shows friction in the wild; not on the M2 path.
diff --git a/spikes/WslcApiDump/InternalComProbe.cs b/spikes/WslcApiDump/InternalComProbe.cs
new file mode 100644
index 0000000..0420402
--- /dev/null
+++ b/spikes/WslcApiDump/InternalComProbe.cs
@@ -0,0 +1,282 @@
+using System.Runtime.InteropServices;
+
+namespace WslcApiDump;
+
+///
+/// D13's open question, reduced to one run (docs/WINDOWS_PORT.md §13.2).
+///
+/// The plan routes container enumeration, session/container reattach and the Terminal panel's pty
+/// to the service-internal COM interface `IWSLCSessionManager` (IID 82A7ABC8-…). Reading
+/// `wslc.idl` turned up a problem the plan assumed away: **it declares interfaces and no
+/// activatable class.** There is no CLSID in it, so `CoCreateInstance` has nothing to name, and
+/// the only registered coclasses anywhere in WSL's IDLs belong to the *compat* surface and to the
+/// WSL service proper.
+///
+/// The likely answer is that one of those coclasses also implements the internal interface — COM
+/// objects routinely expose several — and `wslc.exe` simply QIs for it. That is a yes/no question,
+/// and everything downstream depends on it:
+///
+/// * **yes** → write the internal arm (`ListContainers`, `OpenSessionByName`, `OpenContainer`,
+/// `ResizeTty`), and a broker restart stops being fatal to a running sandbox.
+/// * **no** → D13 needs reopening. The realistic alternatives are a durable host-side roster
+/// plus `wsl --shutdown` on restart, or revisiting the rejected `wslc.exe` arm for enumeration.
+///
+/// Two stages, because they carry very different risk:
+///
+/// * --internal activates each candidate class and QIs for the internal IIDs. It never
+/// calls a method, so **it cannot crash on a vtable mismatch** — and QI alone answers the
+/// entry-point question.
+/// * --internal-call additionally calls through the interface (`GetVersion`, then
+/// `ListSessions`). This one CAN take the process down if the real vtable differs from the
+/// IDL — which is itself a finding, and the reason it is opt-in rather than default.
+///
+internal static class InternalComProbe
+{
+ // The only registered coclasses in WSL's IDLs. wslc.idl contributes none — that is the
+ // problem — so every candidate here comes from WSLCCompat.idl or wslservice.idl.
+ private static readonly (string Name, Guid Clsid, string Source)[] Candidates =
+ [
+ ("WSLCCompatSessionManager", new Guid("a9b7a1b9-0671-405c-95f1-e0612cb4ce8f"),
+ "WSLCCompat.idl — what the SDK itself activates, so the likeliest host"),
+ ("WSLCCompatSessionManagerFactory", new Guid("9fcd2067-9fc6-4efa-9eb0-698169ebf7d3"),
+ "WSLCCompat.idl"),
+ ("LxssUserSession", new Guid("a9b7a1b9-0671-405c-95f1-e0612cb4ce7e"),
+ "wslservice.idl — the WSL service proper; note it differs from the compat CLSID "
+ + "only in the last byte (ce7e vs ce8f)"),
+ ("LxssUserSessionInBox", new Guid("4f476546-b412-4579-b64c-123df331e3d6"),
+ "wslservice.idl"),
+ ];
+
+ private static readonly (string Name, Guid Iid, string Why)[] Interfaces =
+ [
+ ("IWSLCSessionManager", new Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"),
+ "THE one that matters — OpenSessionByName/EnterSession/ListSessions, i.e. reattach"),
+ ("IWSLCSession", new Guid("EF0661E4-6364-40EA-B433-E2FDF11F3519"),
+ "ListContainers/OpenContainer — enumeration and the container half of reattach"),
+ ("IWSLCVirtualMachine", new Guid("B5E2D8F1-9A3C-4E6B-8D1F-7C4A2E9B6D3A"),
+ "GetId → the VM GUID an AF_HYPERV bind needs. Per the IDL only a factory the SYSTEM "
+ + "service owns can produce one, so this is EXPECTED to fail — probed anyway, "
+ + "because §13.2 retracted a decision on that reading and it deserves confirming"),
+ ];
+
+ internal static void Run(bool callThrough)
+ {
+ Console.WriteLine();
+ Console.WriteLine("internal COM probe (docs/WINDOWS_PORT.md §13.2):");
+ Console.WriteLine(" wslc.idl declares no coclass, so the question is whether an EXISTING");
+ Console.WriteLine(" class answers a QI for the internal interfaces.");
+ Console.WriteLine();
+
+ var initialized = CoInitializeEx(IntPtr.Zero, CoinitMultithreaded);
+ // S_FALSE means already initialized on this thread; RPC_E_CHANGED_MODE means the runtime
+ // picked the other apartment. Neither is fatal for an out-of-proc server.
+ if (initialized < 0 && (uint)initialized != RpcEChangedMode)
+ {
+ Console.WriteLine($" CoInitializeEx failed: 0x{initialized:X8} — cannot probe");
+ return;
+ }
+
+ var bound = false;
+ foreach (var (name, clsid, source) in Candidates)
+ {
+ Console.WriteLine($" {name} {{{clsid}}}");
+ Console.WriteLine($" ({source})");
+
+ var iunknown = typeof(object).GUID; // IID_IUnknown
+ var hr = CoCreateInstance(in clsid, IntPtr.Zero, ClsctxAll, in iunknown, out var unknown);
+ if (hr < 0)
+ {
+ Console.WriteLine($" activation failed: {Hresult(hr)}");
+ Console.WriteLine();
+ continue;
+ }
+ Console.WriteLine(" activated");
+
+ try
+ {
+ foreach (var (interfaceName, iid, why) in Interfaces)
+ {
+ var iidLocal = iid;
+ var qi = Marshal.QueryInterface(unknown, in iidLocal, out var candidate);
+ if (qi >= 0)
+ {
+ Console.WriteLine($" QI {interfaceName}: **YES**");
+ if (interfaceName == "IWSLCSessionManager")
+ {
+ bound = true;
+ if (callThrough) CallThrough(candidate);
+ }
+ Marshal.Release(candidate);
+ }
+ else
+ {
+ Console.WriteLine($" QI {interfaceName}: no ({Hresult(qi)})");
+ foreach (var line in Wrap(why)) Console.WriteLine($" {line}");
+ }
+ }
+ }
+ finally
+ {
+ Marshal.Release(unknown);
+ }
+ Console.WriteLine();
+ }
+
+ Console.WriteLine(bound
+ ? " RESULT: IWSLCSessionManager IS reachable. D13's internal arm has an entry point —\n"
+ + " write it (ListContainers, OpenSessionByName, OpenContainer, ResizeTty)\n"
+ + " and record the CLSID that answered in §13.2."
+ : " RESULT: IWSLCSessionManager is NOT reachable from any known coclass. D13 needs\n"
+ + " reopening — see §13.2 for the two alternatives. Re-run with\n"
+ + " --internal-call only if a QI above succeeded; it adds nothing here.");
+ if (!callThrough && bound)
+ Console.WriteLine(" (pass --internal-call to also CALL through it — confirms the vtable "
+ + "matches the IDL, and can crash if it does not)");
+ }
+
+ ///
+ /// Prove the vtable is really the IDL's, not just that the IID is recognised. A QI can succeed
+ /// against an interface whose layout has since moved — `wslc.idl` says outright that breaking
+ /// changes to it are fine — and the first thing that would tell us is a crash here rather
+ /// than in the broker.
+ ///
+ private static void CallThrough(IntPtr manager)
+ {
+ Console.WriteLine(" calling through (vtable check):");
+ IWSLCSessionManager? proxy;
+ try
+ {
+ proxy = (IWSLCSessionManager)Marshal.GetObjectForIUnknown(manager);
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($" could not build the RCW: {e.GetType().Name}: {e.Message}");
+ return;
+ }
+
+ try
+ {
+ var hr = proxy.GetVersion(out var version);
+ Console.WriteLine(hr >= 0
+ ? $" GetVersion() = {version.Major}.{version.Minor}.{version.Revision} "
+ + "— slot 3 matches the IDL"
+ : $" GetVersion() failed: {Hresult(hr)}");
+ // Cross-check against the compat SDK's own GetVersion: the same service answering
+ // both is what says these are two faces of one object rather than a coincidence.
+ if (hr >= 0)
+ Console.WriteLine(" compare with WslcService.GetVersion() above — they "
+ + "should agree");
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($" GetVersion() threw {e.GetType().Name}: {e.Message}");
+ return;
+ }
+
+ try
+ {
+ var hr = proxy.ListSessions(out var sessions, out var count);
+ if (hr < 0)
+ {
+ Console.WriteLine($" ListSessions() failed: {Hresult(hr)}");
+ return;
+ }
+ Console.WriteLine($" ListSessions() = {count} session(s)");
+ for (var i = 0; i < count; i++)
+ {
+ var entry = Marshal.PtrToStructure(
+ sessions + i * Marshal.SizeOf());
+ Console.WriteLine($" #{entry.SessionId} \"{entry.DisplayName}\" "
+ + $"(creator pid {entry.CreatorPid})");
+ }
+ // The callee allocated it; nobody else will free it.
+ if (sessions != IntPtr.Zero) Marshal.FreeCoTaskMem(sessions);
+ Console.WriteLine(" → enumeration and reattach are both reachable from here.");
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($" ListSessions() threw {e.GetType().Name}: {e.Message}");
+ }
+ }
+
+ private static IEnumerable Wrap(string text)
+ {
+ var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var line = new System.Text.StringBuilder();
+ foreach (var word in words)
+ {
+ if (line.Length + word.Length + 1 > 88 && line.Length > 0)
+ {
+ yield return line.ToString();
+ line.Clear();
+ }
+ if (line.Length > 0) line.Append(' ');
+ line.Append(word);
+ }
+ if (line.Length > 0) yield return line.ToString();
+ }
+
+ private static string Hresult(int code) => (uint)code switch
+ {
+ 0x80040154 => "REGDB_E_CLASSNOTREG — that class is not registered on this machine",
+ 0x80004002 => "E_NOINTERFACE — the class does not implement it",
+ 0x80070005 => "E_ACCESSDENIED — registered, but this token may not activate it",
+ 0x800401F0 => "CO_E_NOTINITIALIZED",
+ _ => $"0x{code:X8}",
+ };
+
+ // MARK: - Interop
+
+ private const uint ClsctxAll = 0x17; // INPROC_SERVER|HANDLER|LOCAL_SERVER|REMOTE_SERVER
+ private const uint CoinitMultithreaded = 0;
+ private const uint RpcEChangedMode = 0x80010106;
+
+ [DllImport("ole32.dll")]
+ private static extern int CoInitializeEx(IntPtr reserved, uint coInit);
+
+ [DllImport("ole32.dll")]
+ private static extern int CoCreateInstance(
+ in Guid clsid, IntPtr outer, uint clsContext, in Guid iid, out IntPtr instance);
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct WslcVersion
+ {
+ public uint Major;
+ public uint Minor;
+ public uint Revision;
+ }
+
+ /// Matches `WSLCSessionListEntry` in wslc.idl: two 32-bit fields then two INLINE wide-char
+ /// buffers (not pointers), which is why these are ByValTStr and why the sizes must be exact.
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct WslcSessionListEntry
+ {
+ public uint SessionId;
+ public uint CreatorPid;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string DisplayName;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 257)] public string Sid;
+ }
+
+ ///
+ /// Declared only as far as ListSessions — but every preceding method must still be
+ /// declared with the right parameter count, because a vtable is addressed by slot. The
+ /// unused ones take IntPtr placeholders for their struct and interface pointers.
+ /// PreserveSig throughout, so a failing HRESULT is a value to report rather than an
+ /// exception to decode.
+ ///
+ [ComImport, Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"),
+ InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ private interface IWSLCSessionManager
+ {
+ [PreserveSig] int GetVersion(out WslcVersion version);
+ [PreserveSig] int CreateSession(IntPtr settings, uint flags, IntPtr warningCallback, out IntPtr session);
+ [PreserveSig] int EnterSession(
+ [MarshalAs(UnmanagedType.LPWStr)] string displayName,
+ [MarshalAs(UnmanagedType.LPWStr)] string storagePath,
+ IntPtr warningCallback, out IntPtr session);
+ [PreserveSig] int ListSessions(out IntPtr sessions, out uint count);
+ [PreserveSig] int OpenSession(uint id, out IntPtr session);
+ [PreserveSig] int OpenSessionByName(
+ [MarshalAs(UnmanagedType.LPWStr)] string displayName, out IntPtr session);
+ }
+}
diff --git a/spikes/WslcApiDump/Program.cs b/spikes/WslcApiDump/Program.cs
index abcb607..e9461ec 100644
--- a/spikes/WslcApiDump/Program.cs
+++ b/spikes/WslcApiDump/Program.cs
@@ -20,6 +20,10 @@ namespace WslcApiDump;
/// constructor is lazy and always succeeds; `Start()` is where the service is consulted, and it
/// refuses with `ERROR_ALREADY_EXISTS`. So reattach needs the internal COM interface, which is
/// what D13 assumes (docs/WINDOWS_PORT.md §13.1).
+///
+/// `--internal` then asks the question D13 itself rests on and nobody has answered: is that
+/// internal interface reachable? `wslc.idl` declares no activatable class, so there is no CLSID
+/// to name — see . `--internal-call` also calls through it.
///
internal static class Program
{
@@ -96,6 +100,12 @@ internal static class Program
}
}
+ // Independent of the compat-surface checks above: this asks whether the SERVICE-INTERNAL
+ // interface can be reached at all, which is what D13's enumeration/reattach/pty arm
+ // hangs on (docs/WINDOWS_PORT.md §13.2).
+ if (args.Contains("--internal") || args.Contains("--internal-call"))
+ InternalComProbe.Run(callThrough: args.Contains("--internal-call"));
+
Console.WriteLine();
if (componentsMissing is { Count: > 0 })
{