From 6777223e3d1b697848dccd618ab7cf644a2a2e99 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Wed, 29 Jul 2026 18:57:59 -0700 Subject: [PATCH] Merge nucleic/lucid-river-toad-6efj into dev --- NucleicBroker/IWslc.cs | 12 ++- spikes/README.md | 9 +- spikes/WslcSpike/Program.cs | 164 ++++++++++++++++++++++++++++++++++-- 3 files changed, 173 insertions(+), 12 deletions(-) diff --git a/NucleicBroker/IWslc.cs b/NucleicBroker/IWslc.cs index 0ec29e4..68d0e42 100644 --- a/NucleicBroker/IWslc.cs +++ b/NucleicBroker/IWslc.cs @@ -121,8 +121,16 @@ public sealed class WslcError(string kind, string message) : Exception(message) public const string StartFailed = "start_failed"; public const string AiUnavailable = "ai_unavailable"; - /// The installed wslc cannot do this at all (no pty, an unmappable signal). A - /// permanent capability gap, not a transient failure — hostd must not retry. + /// + /// 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. + /// public const string Unsupported = "unsupported"; /// diff --git a/spikes/README.md b/spikes/README.md index c704c9b..3cb9956 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -194,7 +194,7 @@ Options: `--image` (default `ghcr.io/abkslm/naros-agent:26.07`), `--container`, `--iterations` (timing runs, default 5), `--broker ` if autodiscovery fails, `--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` → 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 fails `session_exists`. It distinguishes "Tier 1 never bound" from "Tier 1 bound and did not 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 non-zero only when a step that should have worked threw. The `[brokerd]` lines interleaved in the diff --git a/spikes/WslcSpike/Program.cs b/spikes/WslcSpike/Program.cs index 2b27477..b0772c3 100644 --- a/spikes/WslcSpike/Program.cs +++ b/spikes/WslcSpike/Program.cs @@ -146,7 +146,14 @@ internal static class Program ["hostname"] = "nucleic-spike", ["env"] = new Dictionary { ["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.start", 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'"]); Console.WriteLine($" exit {lsCode}\n{Indent(lsOut)}"); + await ProbeControlPlaneAsync(broker, containerName, gateway); + await MeasureNinePAsync(broker, containerName, iterations); Step("container.stats (cgroup read — no GetStatistics() exists)"); @@ -204,6 +213,117 @@ internal static class Program Console.WriteLine(" stopped and deleted"); } + /// + /// 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. + /// + 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 "); + + 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(); + } + } + /// /// §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 @@ -248,18 +368,44 @@ internal static class Program var local = await TimeCommandAsync(broker, container, $"cd /tmp/ext4 && {probe}", iterations); 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) { var ratio = Median(mounted) / Math.Max(1, Median(local)); - Console.WriteLine($" → 9P is {ratio:F1}x the local time (median)"); - // The threshold is a judgement call, not a measurement, so it is stated as one. - Console.WriteLine(ratio switch + // Report BOTH, because they lead to different conclusions: a large ratio on a small + // absolute time is tolerable, and that is the distinction the first `find`-based run + // 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.", - < 5 => " → noticeable. D8 stands, but cache dirs (node_modules, build output)\n" - + " should go on a ContainerNamedVolume as §15 anticipated.", - _ => " → BAD. §15 says surface this to the user for a decision rather than\n" - + " silently moving repos into ext4. Re-measure on a large repo first.", + < 250 => " → tolerable: D8 stands as written.", + < 2000 => " → a visible pause on every operation, but workable. D8 stands;\n" + + " cache dirs (node_modules, build output) on a ContainerNamedVolume\n" + + " per §15 recover most of it. Compare the write number above —\n" + + " 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.", }); }