From 3e1f90bdacb9abefb9abe5759050c5281c11e742 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Wed, 29 Jul 2026 03:06:18 -0700 Subject: [PATCH] Merge nucleic/lucid-river-toad-6efj into dev --- spikes/README.md | 49 +++-- spikes/WslcApiDump/InternalComProbe.cs | 282 +++++++++++++++++++++++++ spikes/WslcApiDump/Program.cs | 10 + 3 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 spikes/WslcApiDump/InternalComProbe.cs 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 }) {