Files

849 lines
37 KiB
C#
Raw Permalink Normal View History

2026-07-27 22:02:34 -07:00
#if USE_WSLC
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using Sdk = Microsoft.WSL.Containers;
using Windows.Storage.Streams;
2026-07-27 22:02:34 -07:00
namespace NucleicBroker.Wslc;
/// <summary>
/// The real <c>Microsoft.WSL.Containers</c> adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
/// only with <c>-p:UseWslc=true</c>.
///
/// Written against the **measured** 2.9.3 surface, not the documentation: Microsoft Learn's own
/// C# sample uses <c>MemoryMB</c>, <c>CmdLine</c> and <c>DeleteContainerFlags</c>, none of which
/// exist in the shipped assembly. Re-derive with <c>windows/spikes/WslcApiDump</c> after any
/// version bump — that is now its whole job.
///
/// Everything the SDK is aliased as <c>Sdk</c> deliberately: <c>ImageInfo</c>,
/// <c>ContainerInfo</c>, <c>ProcessSettings</c> and <c>Signal</c> all exist BOTH here and in the
/// parent <c>NucleicBroker</c> namespace, and an unqualified <c>using</c> makes each one
/// ambiguous at the point of use.
///
/// **What this facade cannot do, and why that is reported rather than hidden.** The SDK projects
/// <c>WSLCCompat.idl</c>, the deliberately-stable SDK-facing COM surface, and that surface
/// genuinely lacks container enumeration, per-container stats, pty/resize, and any way to
/// re-adopt a running session or container. Two consequences run through this whole file:
///
/// 1. <c>Sdk.Container</c> has **no Name property** and <c>Sdk.Session</c> has no
/// <c>GetContainers()</c>, so a container is reachable only through the handle
/// <c>CreateContainer</c> returned. This class therefore keeps its own name→handle roster,
/// and that roster — not the service — is what <c>container.list</c> answers from.
/// 2. The roster dies with the process. A restarted broker cannot see, address or re-adopt
/// anything it created before, and <c>session.ensure</c> fails
/// <see cref="WslcError.SessionExists"/> because the compat <c>Start()</c> refuses a name that
/// is already running (confirmed on hardware, §13.1). That is the D13 reattach gap.
///
/// Both are declared through <see cref="Capabilities"/> so hostd degrades from the `hello`
/// exchange instead of discovering them at a call site. Closing them needs the service-internal
/// COM interface; see docs/WINDOWS_PORT.md §13.1 for what that costs and what still gates it.
///
/// WslcService (components) → Session (VM host, images) → Container → Process.
/// </summary>
2026-07-27 22:02:34 -07:00
public sealed class WslcFacade : IWslc
{
private IBrokerEvents? events;
private Sdk.Session? session;
private string? gateway;
2026-07-27 22:02:34 -07:00
private readonly SemaphoreSlim sessionGate = new(1, 1);
/// D13's Tier 1 internal-COM arm, or null where it could not bind. Only used to recover an
/// orphaned session after a broker restart; every other call rides the compat SDK.
private readonly WslcInternal? recovery = WslcInternal.TryBind();
/// <summary>
/// Construct the facade with COM security configured first.
///
/// The ordering is load-bearing and easy to lose: `CoInitializeSecurity` must precede the
/// **first COM call in the process**, and the compat SDK makes its own. Doing it in a factory
/// keeps that constraint next to the code that depends on it rather than in `Program.cs`,
/// where a later reorder would silently break session recovery with a `0x80070542` that reads
/// as "not found".
/// </summary>
public static WslcFacade Create()
{
WslcInternal.InitializeSecurity();
return new WslcFacade();
}
/// name → the handle CreateContainer returned. See the class remarks: without this there is
/// no way to address a container at all, because Sdk.Container carries no name.
private readonly Dictionary<string, Entry> containers = [];
private readonly Lock containersLock = new();
/// <summary>
/// A container handle plus what we have learned about it. <c>SetprivWorks</c> is resolved
/// lazily on the first uid-dropping exec and then cached — see
/// <see cref="ResolvePrivilegeDropAsync"/>.
/// </summary>
private sealed class Entry(Sdk.Container container, string image)
{
internal Sdk.Container Container { get; } = container;
internal string Image { get; } = image;
internal bool? SetprivWorks { get; set; }
}
public string? WslcVersion
{
get
{
try
{
var version = Sdk.WslcService.GetVersion();
// The projection doesn't override ToString(), so the default renders the type
// name — format the triple by hand or `hello` reports a class name as a version.
return $"{version.Major}.{version.Minor}.{version.Revision}";
}
catch (Exception)
{
// A missing/too-old WSL throws here. That is an onboarding condition (§8), not a
// broker failure: null is exactly the "wslc absent" signal `hello` is meant to
// carry, and components.missing explains it properly.
return null;
}
}
}
/// <summary>
/// Stats are always offered — not from the SDK (there is no `GetStatistics()`) but from an
/// in-guest cgroup read, which is indistinguishable to hostd. `recover` is added when D13's
/// Tier 1 arm bound: a broker restart re-adopts and clears its orphaned session instead of
/// leaving the user a sandbox only `wsl --shutdown` can fix.
///
/// Still absent, and deliberately: `enumerate` (`container.list` answers from this broker's
/// own roster, not the service), `reattach` (containers do not survive a restart — Tier 2,
/// blocked, §13.2) and `tty` (the compat surface has no pty).
/// </summary>
public IReadOnlyList<string> Capabilities =>
recovery is not null ? ["stats", "recover"] : ["stats"];
2026-07-27 22:02:34 -07:00
public void SetEvents(IBrokerEvents events) => this.events = events;
// MARK: - Components (§8 onboarding)
2026-07-27 22:02:34 -07:00
public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct)
{
// A LIST of Component, not a flags enum — and it answers from OS feature state, so it
// works even when the service class isn't registered. That makes it the one call that
// explains every other failure.
var missing = Sdk.WslcService.GetMissingComponents().Select(c => c.ToString()).ToList();
2026-07-27 22:02:34 -07:00
return Task.FromResult<IReadOnlyList<string>>(missing);
}
public async Task InstallComponentsAsync(CancellationToken ct)
{
var operation = Sdk.WslcService.InstallWithDependenciesAsync();
operation.Progress = (_, progress) => events?.InstallProgress(
progress.Component.ToString(),
progress.Total == 0 ? 0 : 100.0 * progress.Progress / progress.Total);
await operation.AsTask(ct).ConfigureAwait(false);
2026-07-27 22:02:34 -07:00
events?.InstallProgress("done", 100);
}
// MARK: - Session
2026-07-27 22:02:34 -07:00
public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct)
{
await sessionGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (session is not null) return gateway!;
var created = NewSession(spec);
try
{
created.Start();
}
catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists)
2026-07-27 22:02:34 -07:00
{
// The constructor is lazy — it only captures settings — so reaching this means a
// session of this name is genuinely RUNNING, started by a previous broker that
// died. The compat surface cannot re-adopt it, so recovery goes through D13's
// internal-COM arm: open it, note what it was running, terminate it, retry.
created.Dispose();
created = await RecoverAndRestartAsync(spec, ct).ConfigureAwait(false);
2026-07-27 22:02:34 -07:00
}
catch (Exception e) when (e is not WslcError)
{
created.Dispose();
throw Translate(e, WslcError.StartFailed);
}
session = created;
gateway = await ResolveGatewayAsync(ct).ConfigureAwait(false);
return gateway;
2026-07-27 22:02:34 -07:00
}
finally
{
sessionGate.Release();
}
}
/// <summary>A settings-configured session with its handlers already attached. Subscribing
/// must happen BEFORE `Start()`, or a session that dies during boot never reports down.</summary>
private Sdk.Session NewSession(SessionSpec spec)
{
var settings = new Sdk.SessionSettings(spec.Name, spec.DataDir);
if (spec.Cpu is { } cpu) settings.CpuCount = (uint)cpu;
if (spec.MemoryMB is { } memory) settings.MemorySizeInMB = (uint)memory;
var created = new Sdk.Session(settings);
created.Terminated += reason => events?.SessionDown(reason.ToString());
// Not surfaced as an RPC, but it is the only crash detail wslc offers and it is what
// makes a SIGKILLed agent explicable in the host log (the Swift `diagnoseKill` seam).
created.ProcessCrashed += crash => Console.Error.WriteLine(
$"wslc: process {crash.ProcessName} (pid {crash.Pid}) crashed with signal "
+ $"{crash.Signal}; dump at {crash.DumpPath}");
return created;
}
/// <summary>
/// A session of this name is already running and we do not own it — the signature of a broker
/// that died with its sandbox up (docs/WINDOWS_PORT.md §13.2, D13 Tier 1).
///
/// Clear it through the internal COM arm and start fresh. The orphan's containers are lost,
/// which is the deliberate Tier 1 trade: they are lost today too (nothing could reach that
/// session at all), `ContainerManager.reconcile` already copes with an empty sandbox, and the
/// alternative — keeping them alive — is Tier 2 and blocked. What this buys is that a broker
/// restart stops requiring the user to run `wsl --shutdown` by hand.
/// </summary>
private async Task<Sdk.Session> RecoverAndRestartAsync(SessionSpec spec, CancellationToken ct)
{
if (recovery is null)
throw new WslcError(
WslcError.SessionExists,
$"a wslc session named '{spec.Name}' is already running, the compat SDK cannot "
+ "re-adopt it, and the internal COM interface did not bind; run `wsl --shutdown`");
if (recovery.RecoverSession(spec.Name) is null)
throw new WslcError(
WslcError.SessionExists,
$"a wslc session named '{spec.Name}' is already running and could not be "
+ "recovered; run `wsl --shutdown` to clear it");
// Terminate() returns before the VM is gone — the service tears it down asynchronously —
// so the next Start() can still see the old name. Retry rather than reporting a failure
// that a second attempt a moment later would not have hit.
for (var attempt = 0; ; attempt++)
{
await Task.Delay(500, ct).ConfigureAwait(false);
var retry = NewSession(spec);
try
{
retry.Start();
Console.Error.WriteLine($"wslc: session '{spec.Name}' restarted after recovery");
return retry;
}
catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists && attempt < 20)
{
retry.Dispose();
}
catch (Exception e)
{
retry.Dispose();
throw e is WslcError ? e : Translate(e, WslcError.StartFailed);
}
}
}
2026-07-27 22:02:34 -07:00
public Task TerminateSessionAsync(CancellationToken ct)
{
lock (containersLock)
{
foreach (var entry in containers.Values) entry.Container.Dispose();
containers.Clear();
}
2026-07-27 22:02:34 -07:00
session?.Terminate();
session?.Dispose();
2026-07-27 22:02:34 -07:00
session = null;
gateway = null;
2026-07-27 22:02:34 -07:00
return Task.CompletedTask;
}
/// <summary>
/// The address a guest reaches the host on (§5). **No wslc API surfaces it** — there is no
/// gateway property anywhere on Session or Container, and ContainerPortMapping is inbound
/// host→guest, the wrong direction. So it comes from Windows networking instead: the IPv4
/// address the host holds on the WSL vSwitch.
///
/// Polled, because the vNIC appears as the VM boots and Start() returning does not mean it
/// is up yet.
/// </summary>
private static async Task<string> ResolveGatewayAsync(CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
if (FindWslAdapterAddress() is { } address) return address;
if (attempt >= 20)
throw new WslcError(
WslcError.StartFailed,
"no 'vEthernet (WSL)' adapter address after session start — the guest has no "
+ "route to the host control plane");
await Task.Delay(250, ct).ConfigureAwait(false);
}
}
private static string? FindWslAdapterAddress() =>
NetworkInterface.GetAllNetworkInterfaces()
.Where(nic => nic.OperationalStatus == OperationalStatus.Up)
.Where(nic => nic.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
|| nic.Description.Contains("WSL", StringComparison.OrdinalIgnoreCase))
.SelectMany(nic => nic.GetIPProperties().UnicastAddresses)
.Select(unicast => unicast.Address)
.Where(address => address.AddressFamily == AddressFamily.InterNetwork)
.Select(address => address.ToString())
.FirstOrDefault();
private Sdk.Session RequireSession() =>
session ?? throw new WslcError(
WslcError.NotRunning, "no wslc session (call session.ensure first)");
// MARK: - Images
2026-07-27 22:02:34 -07:00
public async Task PullImageAsync(string reference, RegistryAuth? auth, CancellationToken ct)
{
var current = RequireSession();
var options = new Sdk.PullImageOptions(reference);
// RegistryAuth is a plain STRING, not a credentials object — and the string is minted by
// Session.Authenticate against the registry the ref names. GHCR reads a PAT with
// read:packages as the password (ContainerEngine.registryAuth(for:) does the same).
if (auth is { Username: { } username, Password: { } password })
{
try
{
options.RegistryAuth = current.Authenticate(
RegistryUri(reference), username, password);
}
catch (Exception e) when (e is not WslcError)
{
throw Translate(e, WslcError.PullFailed);
}
}
2026-07-27 22:02:34 -07:00
try
{
var operation = current.PullImageAsync(options);
// Progress rides the ASYNC overload only; the sync PullImage reports nothing. This is
// what feeds the existing controlDownloadProgress UI surface.
operation.Progress = (_, progress) => events?.PullProgress(
reference,
progress.Status.ToString(),
(long)progress.CurrentBytes,
(long)progress.TotalBytes);
await operation.AsTask(ct).ConfigureAwait(false);
2026-07-27 22:02:34 -07:00
}
catch (Exception e) when (e is not WslcError && e is not OperationCanceledException)
2026-07-27 22:02:34 -07:00
{
throw Translate(e, WslcError.PullFailed);
2026-07-27 22:02:34 -07:00
}
}
/// <summary>`ghcr.io/abkslm/hydrangeaos-agent:26.07` → `https://ghcr.io`. A ref whose first segment
/// carries no dot or port has no registry host at all (`ubuntu:24.04`), which means Docker
/// Hub.</summary>
private static Uri RegistryUri(string reference)
{
var firstSegment = reference.Split('/')[0];
var isHost = firstSegment.Contains('.') || firstSegment.Contains(':')
|| firstSegment == "localhost";
return new Uri($"https://{(isHost ? firstSegment : "index.docker.io")}");
}
2026-07-27 22:02:34 -07:00
public Task<IReadOnlyList<ImageInfo>> ListImagesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<ImageInfo>>(
RequireSession().GetImages().Select(Describe).ToList());
2026-07-27 22:02:34 -07:00
public Task DeleteImageAsync(string reference, CancellationToken ct)
{
RequireSession().DeleteImage(reference);
return Task.CompletedTask;
}
public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct)
{
var image = RequireSession().GetImages().FirstOrDefault(i => i.Name == reference);
return Task.FromResult(image is null ? null : Describe(image));
}
/// `.Name`/`.Sha256`/`.Size` — not the `.Reference`/`.Digest` the docs show. Sha256 is an
/// IBuffer of raw bytes, so it becomes the `sha256:<hex>` digest the rest of Nucleic speaks.
private static ImageInfo Describe(Sdk.ImageInfo image) =>
new(image.Name, HexDigest(image.Sha256), (long)image.Size);
private static string? HexDigest(IBuffer? buffer)
{
if (buffer is null || buffer.Length == 0) return null;
var bytes = new byte[buffer.Length];
DataReader.FromBuffer(buffer).ReadBytes(bytes);
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
2026-07-27 22:02:34 -07:00
}
// MARK: - Containers
2026-07-27 22:02:34 -07:00
public Task CreateContainerAsync(ContainerCreateSpec spec, CancellationToken ct)
{
var settings = new Sdk.ContainerSettings(spec.Image) { Name = spec.Name };
if (spec.Hostname is { } hostname) settings.HostName = hostname; // capital N
2026-07-27 22:02:34 -07:00
if (spec.NetworkingMode is { } mode)
settings.NetworkingMode = Enum.Parse<Sdk.ContainerNetworkingMode>(mode, ignoreCase: true);
if (spec.Volumes is { Count: > 0 } volumes)
settings.Volumes = volumes
.Select(v => new Sdk.ContainerVolume(v.Host, v.Guest, v.ReadOnly))
.ToList();
// env and initArgv both live on InitProcess, so build it when EITHER is present —
// attaching env to a null InitProcess silently dropped the whole environment before.
if (spec.InitArgv is { Count: > 0 } || spec.Env is { Count: > 0 })
{
var init = new Sdk.ProcessSettings();
if (spec.InitArgv is { Count: > 0 } argv) init.CommandLine = argv.ToList();
if (spec.Env is { Count: > 0 } env) init.EnvironmentVariables = new Dictionary<string, string>(env);
settings.InitProcess = init;
}
2026-07-27 22:02:34 -07:00
try
{
var container = RequireSession().CreateContainer(settings);
lock (containersLock)
{
if (containers.Remove(spec.Name, out var stale)) stale.Container.Dispose();
containers[spec.Name] = new Entry(container, spec.Image);
}
2026-07-27 22:02:34 -07:00
}
catch (Exception e) when (e is not WslcError)
{
throw Translate(e, WslcError.StartFailed);
2026-07-27 22:02:34 -07:00
}
return Task.CompletedTask;
}
private Sdk.Container RequireContainer(string name)
{
lock (containersLock)
{
return containers.TryGetValue(name, out var entry)
? entry.Container
// Not necessarily absent from the SERVICE — absent from this broker's roster,
// which after a restart is everything it ever created. See the class remarks.
: throw new WslcError(WslcError.NotFound, $"no container named {name}");
}
}
2026-07-27 22:02:34 -07:00
public Task StartContainerAsync(string name, CancellationToken ct)
{
try
{
RequireContainer(name).Start();
}
catch (Exception e) when (e is not WslcError)
{
throw Translate(e, WslcError.StartFailed);
}
2026-07-27 22:02:34 -07:00
return Task.CompletedTask;
}
public Task StopContainerAsync(string name, int signal, int graceMs, CancellationToken ct)
{
RequireContainer(name).Stop(MapSignal(signal), TimeSpan.FromMilliseconds(graceMs));
2026-07-27 22:02:34 -07:00
return Task.CompletedTask;
}
public Task DeleteContainerAsync(string name, bool force, CancellationToken ct)
{
var container = RequireContainer(name);
container.Delete(force ? Sdk.DeleteContainerOption.Force : Sdk.DeleteContainerOption.None);
lock (containersLock)
{
if (containers.Remove(name, out var entry)) entry.Container.Dispose();
}
2026-07-27 22:02:34 -07:00
return Task.CompletedTask;
}
/// <summary>
/// The broker's OWN roster, not the service's. The compat SDK has no enumeration call at all,
/// so this cannot see a container this process did not create — which is why `enumerate` is
/// absent from <see cref="Capabilities"/> and why §2.3's post-restart reconcile is a known
/// gap rather than a silently empty answer.
/// </summary>
public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct)
{
lock (containersLock)
{
return Task.FromResult<IReadOnlyList<ContainerInfo>>(
containers
.Select(kv => new ContainerInfo(kv.Key, kv.Value.Image, Describe(kv.Value.Container.State)))
.ToList());
}
}
2026-07-27 22:02:34 -07:00
public Task<string> ContainerStateAsync(string name, CancellationToken ct)
{
lock (containersLock)
2026-07-27 22:02:34 -07:00
{
return Task.FromResult(containers.TryGetValue(name, out var entry)
? Describe(entry.Container.State)
: "absent");
}
2026-07-27 22:02:34 -07:00
}
/// ContainerState is Invalid|Created|Running|Exited|Deleted; the Swift policy layer only
/// distinguishes running/stopped/absent, and a Deleted handle is absent as far as it cares.
private static string Describe(Sdk.ContainerState state) => state switch
2026-07-27 22:02:34 -07:00
{
Sdk.ContainerState.Running => "running",
Sdk.ContainerState.Deleted => "absent",
_ => "stopped",
};
/// <summary>
/// There is no <c>GetStatistics()</c> on the compat surface — <c>Container</c> offers only
/// <c>Id</c>, <c>State</c>, <c>InitProcess</c> and <c>Inspect()</c>. So the counters come from
/// the guest's own cgroup v2 files, read the way a Linux-native engine would (§13.1 finding 2).
///
/// Deliberately `cat` and `echo` only: no awk, no sed. This runs in whatever image the user
/// configured, and hydrashell is `/bin/sh` in hydrangeaOS — the smaller the tool surface, the fewer images
/// this silently fails in.
/// </summary>
public async Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct)
{
var container = RequireContainer(name);
if (container.State != Sdk.ContainerState.Running) return null;
const string script =
"cat /sys/fs/cgroup/cpu.stat 2>/dev/null; echo ---; "
+ "cat /sys/fs/cgroup/memory.current 2>/dev/null; echo ---; "
+ "cat /sys/fs/cgroup/memory.max 2>/dev/null; echo ---; "
+ "cat /sys/fs/cgroup/memory.events 2>/dev/null";
var (exitCode, output) = await RunCapturingAsync(
container, ["/bin/sh", "-c", script], TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
if (exitCode != 0) return null;
var sections = output.Split("---", StringSplitOptions.TrimEntries);
if (sections.Length < 4) return null;
return new ContainerStatsInfo(
CpuUsageUsec: FieldValue(sections[0], "usage_usec") ?? 0,
MemoryUsedBytes: Number(sections[1]) ?? 0,
// `memory.max` reads the literal "max" when the cgroup is unlimited — not a number.
// -1 rather than 0 so "unlimited" stays distinguishable on the wire; the Swift
// consumer clamps with max(0,…) today, which is its existing "unknown".
MemoryLimitBytes: Number(sections[2]) ?? -1,
OomKills: FieldValue(sections[3], "oom_kill"));
}
/// A cgroup "flat keyed" file: `key value` per line.
private static long? FieldValue(string section, string key)
{
foreach (var line in section.Split('\n', StringSplitOptions.TrimEntries))
{
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 2 && parts[0] == key && long.TryParse(parts[1], out var value))
return value;
}
return null;
2026-07-27 22:02:34 -07:00
}
private static long? Number(string section) =>
long.TryParse(section.Trim(), out var value) ? value : null;
// MARK: - Processes
public async Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
2026-07-27 22:02:34 -07:00
{
if (spec.Tty)
// Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
// §7's Terminal panel needs the internal COM interface (IWSLCProcess::ResizeTty).
throw new WslcError(
WslcError.Unsupported,
"the wslc compat SDK cannot allocate a pty; proc.exec(tty:true) is unavailable");
2026-07-27 22:02:34 -07:00
var container = RequireContainer(spec.Container);
var settings = new Sdk.ProcessSettings
2026-07-27 22:02:34 -07:00
{
CommandLine = (await WithPrivilegeDropAsync(spec, ct).ConfigureAwait(false)).ToList(),
OutputMode = Sdk.ProcessOutputMode.Event,
2026-07-27 22:02:34 -07:00
};
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
if (spec.Env is { Count: > 0 } env)
settings.EnvironmentVariables = new Dictionary<string, string>(env); // not Environment
2026-07-27 22:02:34 -07:00
Sdk.Process process;
try
{
// CreateProcess then Start() — two steps, and the split is the point: handlers are
// attached in between, so the first output chunk cannot be missed.
process = container.CreateProcess(settings);
}
catch (Exception e) when (e is not WslcError)
{
throw Translate(e, WslcError.StartFailed);
}
// OutputReceived and ErrorReceived are SEPARATE events, each carrying only bytes — there
// is no stderr flag to read, so the stream is decided by which handler fired.
process.OutputReceived += data => events?.ProcOutput(procId, stderr: false, data);
process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
2026-07-27 22:02:34 -07:00
process.Exited += code => events?.ProcExited(procId, code);
// NOT started here — BrokerService starts it after the `procId` response is on the wire.
// See IWslcProcess.StartAsync for why that ordering is load-bearing.
return new WslcProcess(process);
2026-07-27 22:02:34 -07:00
}
/// <summary>
/// <c>ProcessSettings</c> has no <c>UserId</c>/<c>GroupId</c>, so dropping to the agent uid is
/// done in-guest by wrapping argv — the fallback §3.2 always named, which costs nothing
/// because the interceptors and hydrashell never look at the numeric uid.
///
/// argv is passed to `setpriv` directly rather than through a shell, so nothing here can be
/// quoted wrong or injected into.
/// </summary>
private async Task<IReadOnlyList<string>> WithPrivilegeDropAsync(
ProcSpec spec, CancellationToken ct)
2026-07-27 22:02:34 -07:00
{
if (spec.Uid is not { } uid || uid == 0) return spec.Argv;
var gid = spec.Gid ?? uid;
if (!await ResolvePrivilegeDropAsync(spec.Container, ct).ConfigureAwait(false))
// Refuse rather than run the agent as root. This path exists because the alternative
// is a silent privilege escalation: the caller asked for uid 501 and got 0, in the
// one place the sandbox's user separation is enforced.
throw new WslcError(
WslcError.Unsupported,
$"cannot drop to uid {uid} in this image: it has no util-linux `setpriv` "
+ "(BusyBox ships a `setpriv` that does not support --reuid). Use an image with "
+ "util-linux, as the hydrangeaOS agent image does — refusing to run as root instead.");
return
[
"setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
.. spec.Argv,
];
}
/// <summary>
/// Does this image have a `setpriv` that can actually change uid? Probed once per container
/// and cached, because the answer is a property of the image and an extra exec per agent
/// command would not be free.
///
/// The probe is `setpriv --reuid=0 --regid=0 --init-groups -- true`: a no-op on util-linux,
/// and an "unrecognized option" failure on BusyBox's namesake, which accepts only capability
/// flags. Testing for the *binary* is not enough — BusyBox has one, it just cannot do this
/// (observed on hardware, docs/WINDOWS_PORT.md §13.3).
/// </summary>
private async Task<bool> ResolvePrivilegeDropAsync(string name, CancellationToken ct)
{
Entry entry;
lock (containersLock)
{
if (!containers.TryGetValue(name, out entry!))
throw new WslcError(WslcError.NotFound, $"no container named {name}");
if (entry.SetprivWorks is { } cached) return cached;
}
bool works;
try
{
var (exitCode, _) = await RunCapturingAsync(
entry.Container,
["setpriv", "--reuid=0", "--regid=0", "--init-groups", "--", "true"],
TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
works = exitCode == 0;
}
catch (Exception)
{
works = false;
}
lock (containersLock) entry.SetprivWorks = works;
if (!works)
Console.Error.WriteLine(
$"wslc: container '{name}' has no usable setpriv — uid-dropping execs will be "
+ "refused rather than run as root");
return works;
}
/// <summary>Run to completion and capture stdout+stderr. The in-guest half of what the macOS
/// engine's `runCapturing` does, and the only way stats and shim re-seeding work without a
/// second mechanism.</summary>
private static async Task<(int ExitCode, string Output)> RunCapturingAsync(
Sdk.Container container, IReadOnlyList<string> argv, TimeSpan timeout, CancellationToken ct)
{
var settings = new Sdk.ProcessSettings
2026-07-27 22:02:34 -07:00
{
CommandLine = argv.ToList(),
OutputMode = Sdk.ProcessOutputMode.Event,
};
var buffer = new MemoryStream();
var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
var process = container.CreateProcess(settings);
try
{
process.OutputReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
process.ErrorReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
process.Exited += code => exited.TrySetResult(code);
process.Start();
int exitCode;
try
{
exitCode = await exited.Task.WaitAsync(timeout, ct).ConfigureAwait(false);
}
catch (TimeoutException)
{
process.Signal(Sdk.Signal.SIGKILL);
throw new WslcError(
WslcError.NotRunning, $"`{string.Join(' ', argv)}` timed out in the container");
}
// Exited and OutputReceived are independent event sources, so a final chunk can still
// be in flight when the exit fires. Waiting a beat costs nothing here (this path is
// never on the agent's hot stdio) and avoids truncating the last line.
await Task.Delay(50, ct).ConfigureAwait(false);
lock (buffer) return (exitCode, System.Text.Encoding.UTF8.GetString(buffer.ToArray()));
}
finally
{
process.Dispose();
2026-07-27 22:02:34 -07:00
}
}
private sealed class WslcProcess(Sdk.Process process) : IWslcProcess
{
public Task StartAsync(CancellationToken ct)
{
try
{
process.Start();
}
catch (Exception e) when (e is not WslcError)
{
process.Dispose();
throw Translate(e, WslcError.StartFailed);
}
return Task.CompletedTask;
}
// stdin is a WinRT stream, not a WriteStdin call, and DataWriter is how you put bytes
// into an IOutputStream. Held for the process lifetime and guarded, because hostd may
// pipeline proc.stdin writes and StoreAsync is not reentrant.
private readonly SemaphoreSlim stdinGate = new(1, 1);
private IOutputStream? stdin;
private DataWriter? writer;
2026-07-27 22:02:34 -07:00
public async Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
2026-07-27 22:02:34 -07:00
{
await stdinGate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (writer is null)
{
stdin = process.GetInputStream();
writer = new DataWriter(stdin);
}
writer.WriteBytes(data.ToArray());
await writer.StoreAsync().AsTask(ct).ConfigureAwait(false);
await writer.FlushAsync().AsTask(ct).ConfigureAwait(false);
}
finally
{
stdinGate.Release();
}
2026-07-27 22:02:34 -07:00
}
public async Task CloseStdinAsync(CancellationToken ct)
2026-07-27 22:02:34 -07:00
{
await stdinGate.WaitAsync(ct).ConfigureAwait(false);
try
{
// Detach before disposing the writer, or the writer takes the stream down with it
// and the close races the last StoreAsync. Closing the STREAM is what the guest
// observes as EOF on stdin.
writer?.DetachStream();
writer?.Dispose();
writer = null;
stdin?.Dispose();
stdin = null;
}
finally
{
stdinGate.Release();
}
2026-07-27 22:02:34 -07:00
}
public Task SignalAsync(int signal, CancellationToken ct)
2026-07-27 22:02:34 -07:00
{
process.Signal(MapSignal(signal));
2026-07-27 22:02:34 -07:00
return Task.CompletedTask;
}
public Task ResizeAsync(int cols, int rows, CancellationToken ct) =>
Task.FromException(new WslcError(
WslcError.Unsupported,
"the wslc compat SDK has no pty, so there is no terminal to resize"));
}
// MARK: - Error translation
/// <summary>
/// POSIX int → the named <c>Signal</c> enum. The RPC carries POSIX numbers because every
/// caller above it is platform-neutral, but wslc accepts only six, so anything else (SIGUSR1,
/// SIGWINCH) is a permanent gap rather than a number to pass through.
/// </summary>
private static Sdk.Signal MapSignal(int signal) => signal switch
{
0 => Sdk.Signal.None,
1 => Sdk.Signal.SIGHUP,
2 => Sdk.Signal.SIGINT,
3 => Sdk.Signal.SIGQUIT,
9 => Sdk.Signal.SIGKILL,
15 => Sdk.Signal.SIGTERM,
_ => throw new WslcError(
WslcError.Unsupported,
$"wslc cannot deliver signal {signal} (only HUP, INT, QUIT, KILL and TERM)"),
};
private const int ErrorAlreadyExists = unchecked((int)0x800700B7);
private static int HResultOf(Exception e) => e is COMException com ? com.HResult : e.HResult;
/// <summary>
/// COM HRESULTs → the RPC's `data.kind`. Keyed on the number, never the message: these
/// exceptions frequently arrive with an EMPTY message, so string matching would silently
/// classify every one of them as the fallback.
/// </summary>
private static WslcError Translate(Exception e, string fallbackKind)
{
var code = HResultOf(e);
var kind = (uint)code switch
{
0x80040601 => WslcError.NotFound, // WSLC_E_IMAGE_NOT_FOUND
0x80040603 => WslcError.NotFound, // WSLC_E_CONTAINER_NOT_FOUND
0x80040605 => WslcError.NotRunning, // WSLC_E_CONTAINER_NOT_RUNNING
0x8004060F => WslcError.NotFound, // WSLC_E_SESSION_NOT_FOUND
0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
// ERROR_ALREADY_EXISTS is CONTEXT-FREE: wslc returns it for a session name conflict
// AND a container name conflict. It used to map to session_exists here, which made a
// stale container report itself as a stuck session — a wrong diagnosis with a wrong
// remedy (`wsl --shutdown` instead of removing one container). The session paths catch
// this code by number before reaching Translate, so anything arriving here is the
// other kind.
0x800700B7 => WslcError.AlreadyExists, // ERROR_ALREADY_EXISTS (container name in use)
// Nothing installed vs. installed-but-too-old: opposite diagnoses, and both mean the
// sandbox is unusable rather than this call being wrong.
0x80040154 => WslcError.Unavailable, // REGDB_E_CLASSNOTREG
0x80070032 => WslcError.Unavailable, // ERROR_NOT_SUPPORTED
0x8004060B => WslcError.Unavailable, // WSLC_E_SDK_UPDATE_NEEDED
0x8004060D => WslcError.PullFailed, // WSLC_E_REGISTRY_BLOCKED_BY_POLICY
_ => fallbackKind,
};
var message = string.IsNullOrWhiteSpace(e.Message) ? $"wslc failed (0x{code:X8})" : e.Message;
return new WslcError(kind, message);
2026-07-27 22:02:34 -07:00
}
}
#endif