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.",
});
}