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

This commit is contained in:
2026-07-29 18:57:59 -07:00
parent e41bafdb7c
commit 6777223e3d
3 changed files with 173 additions and 12 deletions
+10 -2
View File
@@ -121,8 +121,16 @@ public sealed class WslcError(string kind, string message) : Exception(message)
public const string StartFailed = "start_failed"; public const string StartFailed = "start_failed";
public const string AiUnavailable = "ai_unavailable"; public const string AiUnavailable = "ai_unavailable";
/// <summary>The installed wslc cannot do this at all (no pty, an unmappable signal). A /// <summary>
/// permanent capability gap, not a transient failure — hostd must not retry.</summary> /// This broker build cannot serve the call as asked — no pty on the compat surface, a signal
/// outside wslc's six, an image whose `setpriv` cannot drop uid.
///
/// "Not implemented yet", NOT "will never work": each of these has a route (the internal COM
/// pty, a wider signal map, a different image) that is simply **deprioritized until it can no
/// longer be avoided**. What the kind tells hostd is only that *retrying the same call against
/// the same broker will not help* — so surface it and move on rather than backing off and
/// trying again. Closing any of them is a change here, not a change in what wslc can do.
/// </summary>
public const string Unsupported = "unsupported"; public const string Unsupported = "unsupported";
/// <summary> /// <summary>
+8 -1
View File
@@ -194,7 +194,7 @@ Options: `--image` (default `ghcr.io/abkslm/naros-agent:26.07`), `--container`,
`--iterations` (timing runs, default 5), `--broker <path>` if autodiscovery fails, `--iterations` (timing runs, default 5), `--broker <path>` if autodiscovery fails,
`--no-recovery` to skip the crash test. `--no-recovery` to skip the crash test.
It answers three open questions: It answers four open questions:
1. **Does the happy path work?** session → GHCR pull → container with an NTFS `ContainerVolume` → 1. **Does the happy path work?** session → GHCR pull → container with an NTFS `ContainerVolume` →
exec → stdio round-trip → signal → teardown. Also prints the §5 host gateway, which no wslc exec → stdio round-trip → signal → teardown. Also prints the §5 host gateway, which no wslc
@@ -210,6 +210,13 @@ It answers three open questions:
crash — starts a fresh one, and reports whether the orphan was cleared or whether it still crash — starts a fresh one, and reports whether the orphan was cleared or whether it still
fails `session_exists`. It distinguishes "Tier 1 never bound" from "Tier 1 bound and did not fails `session_exists`. It distinguishes "Tier 1 never bound" from "Tier 1 bound and did not
work", because those are different bugs. work", because those are different bugs.
4. **Can the guest reach the host at the gateway? (M1 b)** The last thing M2 waits on. It binds a
listener on the WSL-facing address only — never `0.0.0.0`, which is the posture §5 requires —
and has the container open a TCP round trip to it. Every agent session rides this: approvals,
the git/gh interceptors, nash's shell reports. On failure it prints the `New-NetFirewallRule`
remediation §5 step 5 calls for, and notes that the AF_HYPERV fallback needs a VM GUID that
§13.2 found no client route to — so gateway TCP is not merely preferred, it is the only path
currently available.
Every step prints what happened rather than asserting: a spike's product is evidence. It exits Every step prints what happened rather than asserting: a spike's product is evidence. It exits
non-zero only when a step that should have worked threw. The `[brokerd]` lines interleaved in the non-zero only when a step that should have worked threw. The `[brokerd]` lines interleaved in the
+155 -9
View File
@@ -146,7 +146,14 @@ internal static class Program
["hostname"] = "nucleic-spike", ["hostname"] = "nucleic-spike",
["env"] = new Dictionary<string, string> { ["NUCLEIC_SPIKE"] = "1" }, ["env"] = new Dictionary<string, string> { ["NUCLEIC_SPIKE"] = "1" },
}; };
if (sleepInit) create["initArgv"] = new[] { "/bin/sh", "-c", "sleep 3600" }; // Mirror WslcContainerEngine: narOS images carry no default CMD (wslc answers "no command
// specified"), so the engine always names init explicitly — `naros-init` is PID 1 per
// docs/NAROS.md §5, doing zombie reaping, signal forwarding and, with NAROS_BRIDGE=1,
// supervising control-bridge.js. `--sleep-init` swaps in a keepalive for stock images that
// have no naros-init, which is the case the engine handles with a probe (see §13.3).
create["initArgv"] = sleepInit
? new[] { "/bin/sh", "-c", "sleep 3600" }
: new[] { "/usr/sbin/naros-init" };
await broker.CallAsync("container.create", create); await broker.CallAsync("container.create", create);
await broker.CallAsync("container.start", new { name = containerName }); await broker.CallAsync("container.start", new { name = containerName });
var state = (await broker.CallAsync("container.state", new { name = containerName })) var state = (await broker.CallAsync("container.state", new { name = containerName }))
@@ -189,6 +196,8 @@ internal static class Program
["/bin/sh", "-c", "ls /work | head -5; echo ---; test -d /work/.git && echo 'git repo present'"]); ["/bin/sh", "-c", "ls /work | head -5; echo ---; test -d /work/.git && echo 'git repo present'"]);
Console.WriteLine($" exit {lsCode}\n{Indent(lsOut)}"); Console.WriteLine($" exit {lsCode}\n{Indent(lsOut)}");
await ProbeControlPlaneAsync(broker, containerName, gateway);
await MeasureNinePAsync(broker, containerName, iterations); await MeasureNinePAsync(broker, containerName, iterations);
Step("container.stats (cgroup read — no GetStatistics() exists)"); Step("container.stats (cgroup read — no GetStatistics() exists)");
@@ -204,6 +213,117 @@ internal static class Program
Console.WriteLine(" stopped and deleted"); Console.WriteLine(" stopped and deleted");
} }
/// <summary>
/// M1 (b), and the last thing M2 waits on: can a container reach the host at the gateway?
///
/// Every agent session depends on this. `control-bridge.js` forwards guest loopback 9099 to
/// `NUCLEIC_CONTROL_HOST/PORT`, which is where `MCPApprovalServer` serves approvals, the git
/// and gh interceptor endpoints, and the nash shell reports (§5, §1.4). If the guest cannot
/// open a TCP connection to the host on that address, none of it works and no agent can run.
///
/// This is the reachability question in isolation: a bare TCP round trip, bound **only** to
/// the WSL-facing address and never `0.0.0.0`, which is the posture §5 requires and therefore
/// the posture worth testing. It answers it under whatever firewall policy the machine
/// actually has — the variable §5 step 5 flags and cannot predict.
/// </summary>
private static async Task ProbeControlPlaneAsync(
BrokerClient broker, string container, string? gateway)
{
Step("§5 control plane: can the guest reach the host at the gateway?");
if (!System.Net.IPAddress.TryParse(gateway, out var address))
{
Console.WriteLine($" no usable gateway address ('{gateway}') — cannot test");
return;
}
var listener = new System.Net.Sockets.TcpListener(address, 0);
try
{
listener.Start();
}
catch (System.Net.Sockets.SocketException e)
{
// Binding the WSL-facing address is what MCPApprovalServer will do, so a failure here
// is a finding about §5 rather than about this spike.
Console.WriteLine($" could not bind {gateway}:0 — {e.SocketErrorCode}: {e.Message}");
return;
}
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
Console.WriteLine($" host listening on {gateway}:{port} (interface-scoped, not 0.0.0.0)");
// Answer with a minimal HTTP response as well as reading the request, so both `nc` and
// `wget` work as the guest client — the real bridge speaks HTTP to /mcp.
var received = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
var buffer = new byte[512];
var read = await stream.ReadAsync(buffer);
const string body = "NUCLEIC-CONTROL-OK";
var response = "HTTP/1.1 200 OK\r\n"
+ $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n{body}";
await stream.WriteAsync(System.Text.Encoding.UTF8.GetBytes(response));
await stream.FlushAsync();
return System.Text.Encoding.UTF8.GetString(buffer, 0, read);
});
try
{
// narOS has neither `nc` nor `wget` — it has **node**, since control-bridge.js is the
// real client on this path. Passed as argv with no shell, so nothing here needs
// quoting, and the payload is a bare "PING" because the host side replies to whatever
// it reads (no CRLF handling to get wrong).
var script =
"const net=require('net');const s=net.connect(" + port + ",'" + gateway + "');"
+ "let d='';s.setTimeout(5000,()=>{console.error('TIMEOUT');process.exit(4)});"
+ "s.on('connect',()=>s.write('PING'));s.on('data',c=>d+=c);"
+ "s.on('close',()=>{process.stdout.write(d);process.exit(0)});"
+ "s.on('error',e=>{console.error('CONNECT-ERROR '+e.code);process.exit(3)});";
string[] argv = ["node", "-e", script];
Console.WriteLine($" guest: node -e <tcp connect to {gateway}:{port}>");
var (code, output) = await ExecAsync(broker, container, argv, timeoutSeconds: 60);
// A missing client tool is NOT a blocked connection, and reporting it as one sends the
// reader off to inspect firewall rules for no reason. An earlier version of this probe
// did exactly that on narOS (§13.3).
if (code == 127 || output.Contains("command not found") || output.Contains("not found"))
{
Console.WriteLine($" INCONCLUSIVE — no usable client in this image (exit {code}): "
+ output.Trim());
Console.WriteLine(" → says nothing about reachability. Use an image with node, "
+ "nc or wget.");
return;
}
if (output.Contains("NUCLEIC-CONTROL-OK"))
{
var got = await received.WaitAsync(TimeSpan.FromSeconds(5));
Console.WriteLine(" REACHABLE — the guest completed a TCP round trip to the host.");
Console.WriteLine($" host saw: {got.Split('\n')[0].Trim()}");
Console.WriteLine(" → §5's gateway-TCP control plane works on this machine under "
+ "its current firewall policy. M2 is unblocked.");
}
else
{
Console.WriteLine($" NOT REACHABLE (exit {code}): {output.Trim()}");
Console.WriteLine(" → this is the §5 step-5 firewall case. The control plane "
+ "cannot work until it is resolved, so no agent can run:");
Console.WriteLine(" • check the Hyper-V firewall policy for the WSL vSwitch");
Console.WriteLine(" • `New-NetFirewallRule -DisplayName 'Nucleic control' "
+ $"-Direction Inbound -LocalAddress {gateway} -Protocol TCP -Action Allow`");
Console.WriteLine(" • if it stays blocked, §5's AF_HYPERV fallback stops being "
+ "upside and becomes required — but §13.2 found no client route to the VM GUID, "
+ "so that needs a source outside wslc (HCS enumeration).");
}
}
finally
{
listener.Stop();
}
}
/// <summary> /// <summary>
/// §15's top unquantified risk: D8 puts repos on NTFS and bind-mounts them, so every git and /// §15's top unquantified risk: D8 puts repos on NTFS and bind-mounts them, so every git and
/// npm operation crosses 9P. The only honest measurement is the same work on both sides of /// npm operation crosses 9P. The only honest measurement is the same work on both sides of
@@ -248,18 +368,44 @@ internal static class Program
var local = await TimeCommandAsync(broker, container, $"cd /tmp/ext4 && {probe}", iterations); var local = await TimeCommandAsync(broker, container, $"cd /tmp/ext4 && {probe}", iterations);
Report("/tmp/ext4 (container-local)", local); Report("/tmp/ext4 (container-local)", local);
// Reads are only half the story: `npm install` writes tens of thousands of small files,
// which is the case §15 singles out and which a traversal does not exercise at all.
Step("9P vs ext4 — writing 2000 small files (the `npm install` shape)");
const string write =
"rm -rf wbench && mkdir wbench && cd wbench && "
+ "i=0; while [ $i -lt 2000 ]; do echo x > f$i; i=$((i+1)); done && cd .. && rm -rf wbench";
var mountedWrite = await TimeCommandAsync(broker, container, $"cd /work && {write}", 1);
Report("/work (NTFS via 9P)", mountedWrite);
var localWrite = await TimeCommandAsync(broker, container, $"cd /tmp && {write}", 1);
Report("/tmp (container-local)", localWrite);
if (mountedWrite.Count > 0 && localWrite.Count > 0)
Console.WriteLine($" → writes are {Median(mountedWrite) / Math.Max(1, Median(localWrite)):F1}x "
+ $"slower across the mount ({Median(mountedWrite):F0}ms vs {Median(localWrite):F0}ms "
+ "for 2000 files)");
if (mounted.Count > 0 && local.Count > 0) if (mounted.Count > 0 && local.Count > 0)
{ {
var ratio = Median(mounted) / Math.Max(1, Median(local)); var ratio = Median(mounted) / Math.Max(1, Median(local));
Console.WriteLine($" → 9P is {ratio:F1}x the local time (median)"); // Report BOTH, because they lead to different conclusions: a large ratio on a small
// The threshold is a judgement call, not a measurement, so it is stated as one. // absolute time is tolerable, and that is the distinction the first `find`-based run
Console.WriteLine(ratio switch // got wrong by having no absolute figure worth quoting.
Console.WriteLine($" → reads are {ratio:F1}x the local time "
+ $"({Median(mounted):F0}ms vs {Median(local):F0}ms per run)");
// Judged on ABSOLUTE latency, not ratio: what matters to a session is how long a
// status takes, and a big multiple of a tiny number is still a tiny number. §15 says
// surface the result for a decision rather than silently relocating repos, so none of
// these branches change anything.
var absolute = Median(mounted);
Console.WriteLine(absolute switch
{ {
< 2 => " → acceptable: D8 stands, repos stay on NTFS.", < 250 => " → tolerable: D8 stands as written.",
< 5 => " → noticeable. D8 stands, but cache dirs (node_modules, build output)\n" < 2000 => " → a visible pause on every operation, but workable. D8 stands;\n"
+ " should go on a ContainerNamedVolume as §15 anticipated.", + " cache dirs (node_modules, build output) on a ContainerNamedVolume\n"
_ => " → BAD. §15 says surface this to the user for a decision rather than\n" + " per §15 recover most of it. Compare the write number above —\n"
+ " silently moving repos into ext4. Re-measure on a large repo first.", + " writes, not reads, are what npm install pays.",
_ => " → too slow to ignore. Surface to the user (§15): the options are hot-dir\n"
+ " named volumes, or moving the clone into the session's ext4 with host\n"
+ " access over \\\\wsl$. Do not relocate repos silently.",
}); });
} }