Merge nucleic/lucid-river-toad-6efj into dev

This commit is contained in:
2026-07-29 17:09:35 -07:00
parent 1e6068e273
commit e41bafdb7c
7 changed files with 305 additions and 44 deletions
+95 -15
View File
@@ -72,7 +72,17 @@ public sealed class WslcFacade : IWslc
private readonly Dictionary<string, Entry> containers = [];
private readonly Lock containersLock = new();
private sealed record Entry(Sdk.Container Container, string Image);
/// <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
{
@@ -541,7 +551,7 @@ public sealed class WslcFacade : IWslc
// MARK: - Processes
public Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
public async Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
{
if (spec.Tty)
// Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
@@ -553,7 +563,7 @@ public sealed class WslcFacade : IWslc
var container = RequireContainer(spec.Container);
var settings = new Sdk.ProcessSettings
{
CommandLine = WithPrivilegeDrop(spec).ToList(), // CommandLine, not CmdLine
CommandLine = (await WithPrivilegeDropAsync(spec, ct).ConfigureAwait(false)).ToList(),
OutputMode = Sdk.ProcessOutputMode.Event,
};
if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
@@ -578,27 +588,35 @@ public sealed class WslcFacade : IWslc
process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
process.Exited += code => events?.ProcExited(procId, code);
try
{
process.Start();
}
catch (Exception e) when (e is not WslcError)
{
process.Dispose();
throw Translate(e, WslcError.StartFailed);
}
return Task.FromResult<IWslcProcess>(new WslcProcess(process));
// 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);
}
/// <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 nash 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 static IReadOnlyList<string> WithPrivilegeDrop(ProcSpec spec)
private async Task<IReadOnlyList<string>> WithPrivilegeDropAsync(
ProcSpec spec, CancellationToken ct)
{
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 narOS agent image does — refusing to run as root instead.");
return
[
"setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
@@ -606,6 +624,48 @@ public sealed class WslcFacade : IWslc
];
}
/// <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>
@@ -654,6 +714,20 @@ public sealed class WslcFacade : IWslc
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.
@@ -752,7 +826,13 @@ public sealed class WslcFacade : IWslc
0x80040605 => WslcError.NotRunning, // WSLC_E_CONTAINER_NOT_RUNNING
0x8004060F => WslcError.NotFound, // WSLC_E_SESSION_NOT_FOUND
0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
0x800700B7 => WslcError.SessionExists, // ERROR_ALREADY_EXISTS
// 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