Files
nucleic/docs/WINDOWS_PORT.md
T

182 KiB
Raw Blame History

WINDOWS_PORT — Nucleic for Windows, full implementation plan

Status: In execution, 2026-07-31 — §12 work items 110 implemented (item 10's Protocol DLL is Linux-verified and Windows x64 build/export/P/Invoke-verified), and M0 + M1 (a) both complete: the whole Windows manifest builds and NucleicCarbonTests passes on Windows 11 amd64, and the wslc API is verified against a live service. See "Execution status" below for per-item state, and §13.1 for the wslc findings. This document is the working spec for the native Windows port. It is written to be self-contained: an agent picking up any work item below should be able to execute it from this document plus the referenced source files, without access to the planning conversation.

Goal: full feature parity with the macOS app except the macOS-VM / GUI-Linux-VM computer-use subsystem (Sources/NucleicCore/MacVM/), which cannot exist on Windows. Reuse as much existing Swift as possible via the official Swift-for-Windows toolchain. The UI is WinUI 3 (C#). Anything not covered by existing Swift is written in C#, per Microsoft's recommendation for the WSL Container API.


Execution status (updated 2026-07-31)

Work began in worktree keen-river-gecko-yexx and has continued in normal dev worktrees. Verification tiers used below: [macOS ✓] full host build and/or named suites green on a real Mac; [Linux ✓] nucleicd build + NucleicProtocolTests/NucleicCarbonTests green under Swift 6.2.1 Linux; [Windows ✓] built and/or tested on a real Windows 11 amd64 box via windows/build.ps1 (the tier that replaced the old [CI-pending] marker once hardware became available, 2026-07-29). The full macOS suite state: 1448/1449 pass — the one failure (MacVMTests/ensureBaseBundleRefusesAnIncompleteControlPlane) requires a built macOS-VM base image the dev machine lacks; environmental, unrelated to the port. NucleicProtocolTests on Linux are green; the 16 MiB RUDPTests/liveLoopbackReliableInOrderDelivery case completes in ~33.7 seconds in the sandbox, so its correctness-test budget is 60 seconds rather than the former too-tight 30.

Item 1 — Manifest & gating. Done [macOS ✓][Linux ✓]. Package.swift: onWindows / nonDarwin / windowsProtocolOnly flags; NucleicProtocol's swift-crypto + NIO conditions widened to [.linux, .windows]; per-platform NucleicCore excludes via computeNucleicCoreExcludes() (Windows = the Linux list minus Container/ContainerManager.swift, plus the Windows/ dirs excluded everywhere else); Windows-only targets/products (nucleic-hostd, NucleicProtocolC dynamic library + include/nucleic_protocol.h). Structural note: the product/dependency/target lists are HOISTED into typed top-level lets — inlined in Package(...) they exceed the manifest type-checker budget (first hit on the Linux toolchain). NUCLEIC_WINDOWS_PROTOCOL_ONLY=1 (manifest-eval env) narrows the Windows manifest to NucleicProtocol + its tests, because swift test builds every declared target and NucleicCore doesn't compile on Windows until items 9's shims land.

Item 2 — PortableLogging + WindowsSupport. Done [macOS ✓][Linux ✓]. The os.Logger shim moved from LinuxSupport.swift to Sources/NucleicCore/PortableLogging.swift (shared Linux/Windows); Sources/NucleicCore/Windows/WindowsSupport.swift documents which LinuxSupport stand-ins serve Windows as-is. LinuxSupport.swift now also carries a ContainerError mirror (the real enum lives in the Windows-excluded engine file) and its stub ContainerManager is gated !os(Windows).

Item 3 — SecretStore seam. Done [macOS ✓][Linux ✓]. Sources/NucleicCore/SecretStore.swift: protocol SecretStore (get/set/remove by optional service + account, Data values) + SecretStores.default (KeychainSecretStore over the untouched KeychainOwnedAccess on Darwin; FileSecretStore over LinuxSecretStore elsewhere — Windows shares the file backend until the §4.2 DPAPI CredentialStore lands). ~10 call-site files collapsed to single-path code. Constraints honored (audit findings): absent kSecAttrService is load-bearing (most items have none — a default service would orphan them); ControlAuth's Darwin and Linux key names deliberately differ and both are preserved; ClaudeLoginKeychain's Linux leg stays Claude's own credentials file; CarbonKeyEscrow (iCloud-synchronizable) stays OUTSIDE the protocol. No list yet — no consumer exists; add with its first caller.

Item 4 — SandboxEngine seam. Done [macOS ✓][Linux ✓]. Sources/NucleicCore/Container/SandboxEngine.swift with signatures lifted verbatim from ContainerEngine (incl. exec(...memoryLimitBytes:), runCapturing(...gid:), reconcileDisk, checkDefaultImageUpdate); extension ContainerEngine: SandboxEngine {} is empty by construction; ContainerManager.engine retyped to any SandboxEngine (init default ContainerEngine() only where Containerization imports). Added beyond the sketch: sessionHostGateway() (default nil; see item 7) — the pre-container gateway the Windows flow needs.

Item 5 — C# broker. Done for the compat surface [Linux ✓ — 18/18 xUnit on .NET 9, AND the real WslcFacade.cs now COMPILES against the real Microsoft.WSL.Containers 2.9.3]. D13's internal-COM arm has its Tier 1 ("recover") half done and confirmed on hardwareWslc/WslcInternal.cs auto-clears a session orphaned by a dead broker instead of leaving the user a wsl --shutdown (§13.3). Tier 2 ("adopt", keeping containers alive across a restart) is blocked; see §13.2. The subsystem has now RUN end to end (§13.3). Wslc/WslcFacade.cs was rewritten from the transcription against the surface dumped from the shipped assembly (§13.2) — every mechanical correction in §13.1's table, plus four things that table did not have: Session.Authenticate (the producer of the RegistryAuth string, without which a private GHCR pull has no auth path), Container having no Name property, the IBuffer digest, and a targeting-pack floor that made the file unbuildable. It no longer carries "KNOWN WRONG"; it carries a capability report.

Verified in-container on Linux — dotnet build -p:UseWslc=true -p:EnableWindowsTargeting=true restores the preview NuGet and compiles the facade, so the wslc arm is now buildable without Windows hardware. windows/build.ps1 -Target broker runs that build alongside the fake-backed tests, because the tests never compile the one file that touches the SDK.

Landed with it: IWslc.Capabilities, merged into the hello list (§2.3), so hostd learns from the handshake that this facade cannot enumerate, reattach or allocate a pty — with WslcError.Unsupported / .SessionExists as the matching call-site answers. Stats are real: there is no GetStatistics(), so container.stats execs a cgroup v2 read in the guest (cat and echo only — no awk, because the image is the user's). A broker reports ["stats", "recover"] when D13's Tier 1 arm bound and ["stats"] when it did not. windows/Nucleic.sln, windows/props/Directory.Build.props (+ channel defines), windows/NucleicBroker (nucleic-brokerd): the full §3.3 NDJSON JSON-RPC surface behind an internal IWslc; OutboundWriter queues + coalesces stdio notifications (WinRT event threads never block; proc.exit can never overtake output; LF framing pinned — TextWriter.NewLine would be CRLF on Windows); structured facade errors as JSON-RPC -32000 + data.kind (wslc_unavailable, not_found, not_running, image_pull_failed, start_failed, ai_unavailable, plus unsupported and session_exists); ai.generate seam present (UnavailableAiProvider until item 16). data.kind is derived from the HRESULT number, never the message — these exceptions frequently arrive with an empty message, so string matching would classify every one of them as the fallback. windows/NucleicBroker.Tests drives BrokerService with raw NDJSON lines against FakeWslc — these tests pin the wire contract the Swift client (item 6) consumes.

Item 6 — Swift wslc client. Written [CI-pending]. Sources/NucleicCore/Container/Windows/: WslcBrokerClient (spawns brokerd via ChildProcess, reuses JSONRPCConnection(includeVersionHeader: true), routes proc/session/pull notifications, exponential-backoff restart + onReattach), WslcContainerEngine (full SandboxEngine conformance: one session per channel, image ensure with the same registry-auth rule as the macOS engine, the identical in-guest control-plane probe script, stats-delta resource sampling, channel-scoped reconcileDisk), WslcProcessHandle (broker events → the shared LineSplitter framing; forceCloseStreams on broker loss). checkDefaultImageUpdate returns .idle until the registry-HEAD logic relocates behind the broker (M2).

Two fixes after the first live run [Windows ✓ — NucleicCore compiles on titan]:

  • error.data is no longer dropped. JSONRPCConnection.RPCError.server gained kind: String? (populated from error.data.kind), and WslcContainerEngine.callMapped now branches on it instead of inferring from which method failed — a guess that was sometimes wrong, e.g. a proc.exec refused because the image cannot drop privileges is not notRunning. The method-based mapping stays as the fallback for an older broker. Only one call site matched that enum case, and the JSONRPCConnection tests assert the error type, not its payload, so the added associated value is contained. One thing deliberately NOT done: unsupported means this broker build cannot serve the call as asked — not that it never will — and its useful signal is "retrying will not help". ContainerError has no case carrying that, so it maps to .startFailed (which reads as transient) with the reason in the message. A dedicated .unsupported case is the right fix and needs the Darwin definition and its LinuxSupport mirror to move together — not something to slip in from the Windows side.
  • A dropped-notification hang, the mirror of the broker bug in §13.3. WslcProcessHandle registers its sinks only after proc.exec returns, but resolving that request's continuation merely schedules it while the notification drain is a separate task already reading the next line. For a command that finishes instantly, proc.stdout and proc.exit arrived first and route silently discarded both — so wait() never returned. runCapturing was the most exposed path, since every short probe it runs is exactly that case. Fixed by buffering events for not-yet-registered procIds and draining them in register(procId:sinks:), with the exit always kept and the buffer cleared on broker death (procIds restart at 1).

Item 7 — Control plane. Done end-to-end [macOS ✓ incl. MCPApprovalServerTests 46/46 + the 1449-test adapter suites][Linux ✓][Windows leg CI-pending]. Guest: control-bridge.js dials NUCLEIC_CONTROL_HOST/NUCLEIC_CONTROL_PORT when set (unix-socket branch kept for macOS; proxy pair NUCLEIC_PROXY_HOST/PORT supported). Host transport: MCPApprovalServer.swift gained an in-file #if os(Windows) NIO leg — NIOByteConn + NIOByteConnHandler + performNIOBind with the same self-heal + single-flight contract as the Darwin/Linux listeners (in-file rather than §4.3's separate NIOByteConn.swift because ByteConn is deliberately private there); the AF_UNIX members and binds are #if !os(Windows); start(unixSocketPath:) throws on Windows; isUnixSocketListening returns false there. NucleicCore links NIOCore/NIOPosix on .windows. Sequencing: new Sources/NucleicCore/Container/ContainerControlPlane.swiftbringUp(spec:manager:server:) encodes the per-platform ORDER (macOS: ensureRunningstart(unixSocketPath:) → listening guard; Windows: sessionHostGateway()start(host: gateway)spec.withEnvironment(merging:) the endpoint → ensureRunning), and all four backends (Claude, Codex app-server, Codex exec, Grok ACP) are rewired onto it. Supporting seams: ContainerSpec.withEnvironment(merging:); SandboxEngine.sessionHostGateway() (wslc engine ensures the session and returns the gateway; nil on macOS). Accepted gap: the linux_container scratch-container auxiliary listener (ClaudeCodeBackend, try? start(unixSocketPath:)) degrades to no-control-plane on Windows — same behavior custom images already get.

Item 8 — hostd. Bring-up shared + main written [shared: macOS ✓/Linux ✓; Hostd.swift CI-pending]. Sources/NucleicCore/NucleicHeadless.swift factors the headless bring-up out of nucleicd (scrubBlankAPIKeys, makeStore(support:sandboxEngine:) — Windows MUST inject the wslc engine, loadPersistentState); nucleicd is rewired onto it unchanged in behavior. Sources/nucleic-hostd/Hostd.swift: named-mutex single instance (Local\nucleic-hostd-<channel> via CreateMutexW), %APPDATA%\Nucleic<channelSuffix> data root, brokerd spawn + wslc session/gateway warm-up (best-effort — a missing WSL stack is an onboarding condition, not a dead host), engine wiring (pull progress → download-progress surface; reattach → gateway invalidation), lan,relay transport default, SetConsoleCtrlHandlerstopSyncServer + broker shutdown. Still open in hostd: the rendezvous file + local renderer loopback listener (the DLL client half is now present; hostd still needs to expose the local sync port and item 11 needs to consume it) and DNS-SD advertise (item 13).

The post-reattach ContainerManager.reconcile sweep is NOT needed, and was removed from this list after being investigated (2026-07-30). It was specified for a world where containers survive a broker restart — D13 Tier 2. Under Tier 1, which is what ships:

  • Tier 1 terminates the orphaned session, so no container survives for a sweep to find (confirmed on hardware: the recovery run enumerated 5 containers and terminated them, §13.3).
  • WslcContainerEngine.ensureRunning reads containerState first and only creates when the container is absent, so a vanished container is recreated on next use — it self-heals without a sweep.
  • ContainerManager.reconcile(activeSessions:) only forwards to engine.reconcileDisk(keepNames:) anyway. It would not clear the manager's own stale bookkeeping (physicalNames, the active refcounts), so it was never the right tool for "the mechanism restarted underneath us" even in principle. Revisit with Tier 2, where surviving containers make the reconcile meaningful again.

Item 14 (partial) — CI. WITHDRAWN, 2026-07-29 — see §14.0. The authored .github/workflows/windows.yml was deleted rather than enabled; windows/build.ps1 on the dev box is the Windows gate. The description below is retained as the design record for whenever a hosted/self-hosted runner is worth it. It read: .github/workflows/windows.yml: required protocol job (x64 windows-2025 + windows-11-arm, toolchain pinned by windows/props/swift-version.txt, NUCLEIC_WINDOWS_PROTOCOL_ONLY=1); experimental continue-on-error core job — the M0 gap list, flips to required at M2 (includes vcpkg SQLite provisioning for GRDB, §14.2); required broker job (dotnet test windows/Nucleic.sln, no wslc NuGet). runs-on reads WINDOWS_X64_RUNNER/WINDOWS_ARM64_RUNNER repo variables with hosted fallbacks (§14.1). Toolchain pin is swift-6.3.3-RELEASE — floor set by the runner's MSVC (VS 2026 STL needs clang ≥ 20; Swift 6.2.x bundles 19).

Item 9 — Windows shims. Written [macOS ✓ (NucleicCore builds; 129 tests across the 9 touched suites green)][Windows leg CI-pending]. Five new files under Sources/NucleicCore/Windows/ plus two shared seams:

  • WindowsSignals.swiftpid_t / SIGKILL / SIGTERM / SIGINT as module-internal stand-ins (ucrt has no SIGKILL and no kill), so ~30 shared call sites stay platform-free. Deliberately in a file that does NOT import WinSDK, so it can never collide with ucrt's.
  • ProcessTree.swiftkill(pid:sig:) (SIGKILL → TerminateProcess with exit code 137 so Backend.isSIGKILL classifies a killed host command identically everywhere; SIGTERM/SIGINT → GenerateConsoleCtrlEvent, a documented no-op until a spawn site opts into its own process group, with the caller's grace→SIGKILL ladder doing the real work), WindowsJobObject (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, attached in ChildProcess.init right after Process.run() — corelibs exposes neither creation flags nor the process handle), and the Toolhelp / GetProcessTimes / GetProcessIoCounters backings for ProcessHost's three sampling ladders.
  • FilePermissions.swift — protected single-ACE DACL via SetEntriesInAclW + SetNamedSecurityInfoW, behind the new shared SecretFile (below).
  • WindowsEnvironment.swift — registry Path (HKLM Session Manager + HKCU\Environment) for LoginShellPATH, plus setenv/unsetenv over SetEnvironmentVariableW (POSIX-only names that ucrt lacks and NucleicHeadless/RunnerCredentialVault/both hosts call).
  • PowerBlocker.swiftSetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED), driven from a 5-second main-actor tick in hostd off store.localTurnInFlightCount (no SwiftUI onChange headlessly). Default-on, unlike the Mac's opt-in Smart Sleep — a background host that suspends mid-turn kills the agent, the container and the sync link at once — but it reads the same nucleic.power.blockSleep key, so one lever governs both.
  • WindowsFileIO.swiftMoveFileExW(REPLACE_EXISTING|WRITE_THROUGH) for CarbonShardStore.atomicWrite: the CRT's rename fails on an existing destination, which for a content-addressed store turns every idempotent re-write into an error.
  • Shared seams: SecretFile.swift (owner-only create/write/restrict — the POSIX legs are the byte-identical FileManager calls they replaced, ~12 credential sites retargeted) and NucleicPaths.swift (one data root: <Application Support>/Nucleic unchanged on macOS/Linux, %APPDATA%\Nucleic<channel> on Windows, NUCLEIC_DATA_DIR override honored by every derived path — previously hostd's root governed only the store path it passed to makeStore). ApprovalServerRegistry, MoveLog/MeshDebugLog, TailnetTransport and LinuxSecretStore now derive from it. Also in this item, found by sweeping the Windows-compiled file set for POSIX-only symbols: MCPApprovalServer's libc-alias ladder gained an os(Windows) arm (its #else was the Glibc one, so Windows fell into Glibc.bind); SessionController's getuid()/getgid() use ContainerSpec.narosAgentUID on Windows (no host uid to mirror, and the guest runs under WSL); ChildProcess resolves bare executables over PATH × PATHEXT (no /usr/bin/env); LoginShellPATH splits/joins on ; (splitting a Windows PATH on : shreds every C:\… entry) and reads the registry instead of probing a shell, while LoginShellEnv returns nil (a Windows process is born with the merged machine+user environment — nothing to recover); nucleicd left the Windows manifest entirely (nonWindowsTargets/nonWindowsProducts — its control endpoint is a BSD-socket listener with a DispatchSource signal handler, and swift test builds every declared target). FileWatcher from the §4.3 table proved unnecessary — see the table's note.

M0 gap closed, 2026-07-29 — the WHOLE Windows manifest builds, and NucleicCarbonTests runs. build.ps1 -Target core -Test compiled every declared target on Windows 11 amd64 — NucleicCore, nucleic-hostd, NucleicProtocolC, nucleic-smoke, NucleicCarbonTests — and ran 74 Carbon tests, of which 61 passed on the first attempt. All 13 failures had a single cause, and it was a port bug, not a gap: GitRunner has its own spawn path, and item 9 taught only ChildProcess to PATH-search, leaving GitRunner pointing at /usr/bin/env. Windows has no such file, so every git invocation failed with corelibs' "The file doesn't exist". Fixed by giving all three host spawn sites ONE resolver — resolvedSpawnTarget(executable:args:path:) in ProcessHost.swift — rather than a second copy of the rule, since a second copy is precisely what caused it. Two Process spawns elsewhere still hardcode POSIX absolute paths and will need attention before those features work on Windows, neither of them test-visible: NvrsionTrunk.swift's /bin/sh and GitHubCredentials.swift's /usr/bin/ssh-keygen fallback.

This also retired an anti-pattern in the tooling: build.ps1 labelled a failing core -Test "FAIL (expected — items 10+ gap list)" and exited 0, which is how a real regression read as a known gap. No leg is excused now — every one has been green on Windows at least once, so red means look.

NucleicCore BUILDS ON WINDOWS. .\windows\build.ps1 -Target core -Persist is green on Windows 11 amd64 with the item-9 shims in place. That was the M0/M2 risk the whole plan was sequenced around, and it is now behind us: the port is no longer speculative on the Swift side. Consequences recorded in the tooling — the core job's swift build --target NucleicCore step is required on x64 (ARM64 stays continue-on-error, no ARM64 box has run it), and build.ps1 no longer bills a core failure as an expected gap. swift test --filter NucleicCarbonTests stays a per-step probe on both arches, because it builds EVERY declared target — nucleic-hostd, NucleicProtocolC, nucleic-smoke — which are items 10+ work, not NucleicCore.

Item 10 — Protocol DLL. Implemented [Linux ✓: 4/4 ABI/transport + 20/20 projection equivalence + 73/73 bridge/fixture/wire tests under Swift 6.3.3][Windows ✓: x64 DLL build, all 11 np_* PE exports, and a live C# P/Invoke smoke on Titan]. The JSON bridge, its tag surface, complete golden ABI corpus, client lifecycle, transports, callbacks, intent path, and transcript projection entry points are present.

  • Sources/NucleicProtocol/Sync/ProtocolJSON.swiftdivergence from §6, deliberate: the bridge lives in NucleicProtocol, not "in the DLL layer only" as §6 sketched. §6 was written assuming a hand-written per-case JSON mapping; in fact ClientMsg/HostMsg are already Codable (tagged {"t": …}) and the CBOR wire codec is a Codable implementation, so the JSON view rides the same conformance and there is no per-case mapping to write — or to drift. Putting it in NucleicProtocol makes it testable by NucleicProtocolTests, which already runs on macOS, Linux AND the required Windows CI leg; in the DLL it would only be reachable from a Windows-only target. The DLL becomes a veneer.
  • What that leaves as real decisions, all pinned and tested: keys .useDefaultKeys; dates as seconds-since-2001 Double (what CBOR already emits, so a date reads identically on both sides — not ISO8601, not Unix epoch); Data as base64 (the one deliberate divergence from CBOR, which emits a byte array — a 4 KB blob as 4,096 JSON numbers is hostile to byte[]); non-conforming floats as "Infinity"/"-Infinity"/"NaN" rather than a throw; output .sortedKeys + .withoutEscapingSlashes so fixtures compare byte-for-byte.
  • ProtocolJSON.tag(of:) — the "t" discriminator as an exhaustive switch over both enums (115 cases). Adding a message kind is now a compile error until it is named, and a test cross-checks every label against the "t" the encoder actually emits, so the switch cannot silently disagree with the wire. This is the surface a C# discriminator keys on.
  • fixtures/protocol-abi/v1/{client,host}/ now holds 115 paired vectors: the exact canonical .json bytes the C ABI exposes and the exact wire CBOR as .cbor.hex (64 client kinds + 51 known host kinds; HostMsg.unknown is a forward-compat fallback, not a kind). The Swift test catalog constructs one representative of every case, asserts unique/complete tags, verifies both fixture bytes and both decode paths, rejects stale/extra files, and has an explicit NUCLEIC_UPDATE_PROTOCOL_ABI=1 regeneration mode documented in the fixture README. It lives in NucleicProtocolTests, not a Windows-only NucleicProtocolCTests target, so the same gate runs on every platform that builds the wire layer; the C# half joins in item 11 when that test project exists. Building the full catalog exposed and fixed one real drift the old spot-check missed: Swift's case is .transcriptFetchComplete, but its frozen wire tag is "transcriptComplete".
  • NIOClientFrameChannel is the Windows/Linux TCP leg: one shared NIO event-loop thread, the existing four-byte WireFraming/FrameAccumulator, and no changes to SyncClient or Noise. np_client_connect_local accepts hostd's {pid,port,localPSK} rendezvous (also psk as an alias); remote connect accepts pinned LAN/tailnet TCP and relay descriptors; pairing consumes the existing nucleic://pair?d=… payload. direct is correctly rejected as a dial descriptor because it is a negotiated relay upgrade.
  • Client creation persists the 64-byte DeviceIdentity, stable device id, and a base64 host-id→static-key pin table under identityDir; the pin learned at XXpsk0 readiness is committed before the ready state callback. Entry-point return values report synchronous acceptance/validation (0, or stable -1…-4 codes); TCP/Noise outcomes remain asynchronous.
  • The SyncClient.Event mapping is exhaustive at compile time. Every message-shaped event is re-encoded with ProtocolJSON and delivered to np_event_cb; lifecycle events (connecting, transport selection, ready with Welcome + learned host key, failure, closed) go to np_state_cb. A real process-wide single-thread executor—not merely a serial GCD queue—pins both callback types and every client handle to one dedicated DLL thread. np_client_close cancels/drains that handle's pending work and is a callback barrier, so C# may release its callback context as soon as close returns.
  • TranscriptProjectionABI wraps the unchanged shared incremental projector and maintains events per session across snapshot, events, transcript backfill chunks, and reverts. Each np_projection_apply returns a versioned minimal middle splice {start,deleteCount,items}; irrelevant host messages and idempotent replays return no splices. Render-item seq is a decimal string so the full UInt64 survives JSON's 53-bit ecosystem.
  • NucleicProtocolC and its tests are declared on Linux as a compile contract while the dynamic product remains Windows-only. The four tests cover identity/handle ownership, exact projection splices including revert, dedicated callback-thread delivery, and a real NIO TCP frame/deframe echo. The existing incremental-projection equivalence suite now runs on Linux too.
  • Windows hardware rerun (Titan, Swift 6.3.3, x86_64): the full manifest emitted a 69,157,888-byte NucleicProtocolC.dll; llvm-readobj --coff-exports found exactly the 11 header entry points. A live PowerShell-hosted C# P/Invoke smoke loaded that PE, created and closed a client, persisted all three identity files, observed NP_INVALID_STATE (-2) for an intent before connect, projected a canonical pong to {"splices":[],"version":1}, and freed the returned string. NucleicCarbonTests passed 83/83. The protocol suite passed 302/303 on its first run; the only miss was the 16 MiB RUDP loopback reaching the old 30-second test deadline. It failed identically in isolation, then passed with a larger timing probe; Linux measured completion at ~33.7 seconds, so the committed budget is 60 seconds. This validates the DLL half of M1(c); its C# connect-pair-echo host round trip remains item 11.

M1 spike (a), phase 1 — DONE and CONFIRMED ON HARDWARE, 2026-07-29. windows/spikes/WslcApiDump --probe runs clean on Windows 11 amd64 with WSL 2.9.3: all 54 recorded members read ok against the shipped wslcsdkcs.dll (37 API types + 22 ABI projection types), and GetMissingComponents() returns empty. Re-confirmed after the item-5 facade rewrite: the assumption list grew to 59 as the facade took on Session.Authenticate, Container.Id, InstallProgress.*, ContainerState.Deleted and Process.GetInputStream, and all 59 read ok on hardware — so every member the rewritten facade calls exists in the shipped package, not just in the metadata dump it was written from. The surface in §13.1 — derived offline from the .nupkg — is therefore verified against the real assembly, not just read from it. Two environment facts worth keeping: the SDK needs WSL ≥ 2.9.3, which is pre-release-only (wsl --update --pre-release; a plain wsl --update will not reach it), and an installed-but-too-old WSL answers ERROR_NOT_SUPPORTED (0x80070032) rather than the REGDB_E_CLASSNOTREG (0x80040154) a machine with nothing installed gives — opposite diagnoses that look alike.

--session has also now run (§13.1, "live session findings"), and it confirms D13: the Session constructor is lazy (a second one with the same name constructs fine), but Start() refuses with ERROR_ALREADY_EXISTS. The compat surface cannot re-adopt a running session, so §2.3 reattach does need the internal COM interface. Also worth noting for D13's drift concern: the service is 2.9.4 while the NuGet is pinned at 2.9.3 — the two version independently, and already have.

The real wslc API is known; see §13.1. Obtained without Windows hardware: the package is public on nuget.org, so the .nupkg was downloaded, its C#/WinRT projection assembly extracted, and its metadata read with MetadataLoadContext in a Linux container. windows/spikes/WslcApiDump (reflection-based, so it cannot fail to build) remains checked in as the drift detector for the next package version, with its assumption list retargeted from guesses to the surface actually observed.

The transcription in Wslc/WslcFacade.cs was substantially wrong — as expected, which is why it sat behind IWslc — and, more importantly, three of its errors are not typos but missing capability. See §13.1 for the full comparison; the headline is that the SDK has no container enumeration, no per-container statistics, and no pty, and that the §5 gateway address does not come from the SDK at all.

Not started: items 1113, 1516. M1 is complete — (a1) the API surface, (a2) the live happy path, and (b) gateway reachability are all answered on hardware (§13.2, §13.3). The D13 internal-COM arm is unblocked and unwritten — §13.2 records its entry point (confirmed on hardware) and the one vtable check that should precede writing it.

Known Windows gaps opened but not closed by item 9 (each is a listener, not a shim): the Claude token proxy has no Windows host listener — ClaudeTokenProxy's TCP and AF_UNIX legs are both #if canImport(Network), so the opt-in proxy degrades to off on Windows even though control-bridge.js already understands NUCLEIC_PROXY_HOST/PORT; and the OAuth loopback listener (OAuthLoopback.swift, wholly Network.framework) is absent, which is what §8 step 5's agent sign-in needs. Both want the same NIO treatment MCPApprovalServer got in item 7 and are sized with item 11 (renderer onboarding), not here.

Environment/toolchain notes for future agents (also in the session memory): Linux verification ran from an agent sandbox with Swift 6.2.1 (ubuntu24.04 tarball) extracted via Python tarfile — GNU tar's delayed-symlink mode-000 placeholders are unremovable on virtiofs; needs libncurses.so.6libncursesw shim, vendored sqlite3.h + libsqlite3.so symlink, and -Xlinker --allow-shlib-undefined (this toolchain's libswiftObservation references an unexported swift::threading::fatal). Linux resolution rewrites Package.resolved (drops Sparkle) — restore it after builds. Scratch persists in the worktree's .linux-verify/.

The C# legs also run from a Linux agent sandbox, including the wslc facade, which is worth knowing before anyone waits on Windows hardware for a broker change. Install the .NET 9 SDK with dot.net/v1/dotnet-install.sh (it is not preinstalled), and export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 — the sandbox has no ICU, and without it every dotnet invocation dies in CultureInfo's static constructor with a stack trace that names globalization and not the missing package. Then:

dotnet test  windows/Nucleic.sln                                              # fake-backed, 18/18
dotnet build windows/NucleicBroker/NucleicBroker.csproj \
             -p:UseWslc=true -p:EnableWindowsTargeting=true                   # the REAL facade

Limit of ssh-staging for SWIFT changes. Copying individual files onto the Windows box works for self-contained ones (anything under Sources/NucleicCore/Container/Windows/, JSONRPCConnection.swift, Hostd.swift) and fails for files coupled to the rest of the tree. Patching a newer AppStore.swift onto a checkout at an older origin/dev produced errors naming other files' members (SessionController.setRoutingNote, StartChatRequest) — version skew, not a defect in the change. So a cross-cutting Swift change cannot be verified by staging: it needs the branch on origin/dev so the box can pull a coherent tree. Plan Swift work accordingly, or keep it inside the Windows-only files.

EnableWindowsTargeting=true is what lets a non-Windows SDK restore the Windows targeting packs; the preview NuGet itself is public, so it restores anywhere. The same trick reads the API surface without hardware: point MetadataLoadContext at ~/.nuget/packages/microsoft.wsl.containers/<version>/lib/*/wslcsdkcs.dll with the Microsoft.Windows.SDK.NET.Ref assemblies on the resolver path (§13.1's re-derivation recipe, which is how §13.2's findings were obtained).


0. Locked decisions (user-approved — do not relitigate)

# Decision
D1 Headless Swift core + C# renderer. nucleic-hostd.exe (Swift, Windows toolchain) owns sessions, git, storage, approvals, and sync. The WinUI 3 app is a renderer over the existing Noise/CBOR sync protocol via local loopback — the same "N renderers, one authority" pattern the iPhone app uses. No Swift-in-C#-process embedding of the core.
D2 Control-only. Windows supports ONLY Nucleic Control projects. Every agent session runs inside a WSL container. There is no host-spawned agent path on Windows. (Control will be forced on macOS soon too; plan accordingly.)
D3 Containerization = WSL Container API (Microsoft.WSL.Containers NuGet, WinRT; public preview now, GA fall 2026), driven from a C# broker process. Not full VMs, not Docker, not wsl --import distros.
D4 MacVM subsystem excluded (both macOS-guest and GUI-Linux-VM computer-use paths). MDM (NucleicMDM server role) and NucleicPowerHelper are also not ported (macOS-VM-only / macOS-concept).
D5 Distribution: MSIX packaging + per-channel .appinstaller sideload feeds on Cloudflare R2, mirroring the five-channel Sparkle model (dev/canary/beta/rc/stable). Authenticode via Azure Trusted Signing. Store/winget deferred to post-beta.
D6 On-device AI assists: Windows AI Foundry / Phi Silica (Windows App SDK) where hardware supports it; graceful degradation to the existing heuristic fallbacks elsewhere (the same behavior as a Mac without Apple Intelligence).
D7 Renderer protocol client = Swift DLL. NucleicProtocol (+ transcript projections) compiled into NucleicProtocolC.dll exposing a thin flat C ABI; C# P/Invokes it. No C# reimplementation of CBOR/Noise/envelope.
D8 Repos on NTFS. Control clones + worktrees live at %USERPROFILE%\.nucleic\control, bind-mounted into containers via ContainerVolume. Host git ops keep using GitRunner/WorktreeManager with Windows git.exe (bundled MinGit), unchanged. Measured on naros (§13.3): the mount costs ~1523x — git status 1.5 s vs 65 ms, writes 15x. D8 STANDS; the indicated mitigation is write-hot dirs on a ContainerNamedVolume, not relocating the tree. An earlier find-based figure of 164x was inflated ~10x.
D9 Full remote parity: DNS-SD advertise + QR pairing for the iPhone remote over LAN, the relay transport, AND tailnet (libtailscale built for Windows as a DLL).
D10 x64 + ARM64 from day one. The naros-agent OCI image gains a multi-arch (amd64+arm64) build.
D11 Self-hosted Windows CI runners (one x64 first, ARM64 later) for the container e2e suite; hosted runners lack nested virtualization.
D13 Two wslc surfaces, no CLI. The broker binds the stable compat SDK (Microsoft.WSL.Containers) for everything it covers, and the service-internal COM interface (wslc.idlIWSLCSessionManager, IID 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760) for the five things it does not: container enumeration, per-container stats, session/container reattach, pty + resize, and the VM GUID. Shelling out to wslc.exe is rejected — a spawn per call, scraped text, no events, and a second mechanism to maintain. The internal ABI is explicitly unstable, so the broker probes it at startup and degrades (reporting through the capabilities hello) instead of failing. Everything stays behind IWslc. See §13.1. Amended 2026-07-29 (§13.2): the compat arm is written and the degradation reporting is live. The internal arm's entry point is confirmed on hardwarewslc.idl declares no activatable class, but WSLCCompatSessionManager (a9b7a1b9-0671-405c-95f1-e0612cb4ce8f, the same class the SDK activates) answers a QI for IWSLCSessionManager. One row of D13 is withdrawn, though: IWSLCVirtualMachine is unreachable from a client in the shipped IDL — only a factory the SYSTEM service owns produces one — so the control-plane/VM-GUID row has no route today and §5 keeps gateway TCP.
D12 The Windows app is also a remote client. It can pair with and render any Nucleic host (its own hostd, or a Mac) — host-picker UI, reusing the same pairing/connection code.

1. Background: what exists today (verified 2026-07-28)

1.1 The codebase already has the port seams

  • Package.swift (swift-tools-version 6.2) computes onLinux at manifest-eval time (Package.swift:62-64), drops darwinOnlyTargets (:70, :402), conditionalizes dependency lists (:219, :266), and excludes Apple-framework sources from NucleicCore via a per-target exclude: list (:352-381). The manifest is also channel-aware (NUCLEIC_CHANNEL env → product name + NUCLEIC_DEV/CANARY/BETA/RC/STABLE compile define).
  • Source-level gating is almost entirely #if canImport(...) (only 3 #if os(...) in all of Sources/: Claude/ClaudeCredentialBroker.swift:360, Codex/CodexCredentialBroker.swift:166, Project.swift:465).
  • Sources/NucleicCore/LinuxSupport.swift (~443 LOC) provides stub actors (ContainerManager, MacVMManager mirrors that throw LinuxUnsupported) plus an os.Logger shim, so shared call sites compile unchanged on non-Darwin. This is the template for WindowsSupport.swift.
  • Sources/nucleicd/ (645 LOC: Nucleicd.swift, ControlEndpoint.swift) is a proven headless host over NucleicCore for Linux (the Covalence Cloud Runner): one AppStore graph, startSyncServer(), pairing mint, signal-driven shutdown. This is the template for nucleic-hostd.
  • NucleicProtocol builds for macOS, iOS, and Linux already. On non-Apple platforms it swaps CryptoKit→swift-crypto and URLSession-WebSocket→SwiftNIO via #if canImport.

1.2 Target portability inventory

Target LOC (≈) Verdict for Windows
NucleicProtocol (57 files) 13,200 Portable as-is. AgentEvent model, ClientMsg/HostMsg envelope (Sync/MessageEnvelope.swift ~1,079 LOC; Sync/WireMessages.swift ~831), custom CBOR codec (CBOR/), Noise (XXpsk0 pairing + IK reconnect, Noise/), SyncClient.swift (generic over FrameChannel), STUN/UDP punch (Sync/Direct/), relay WebSocket (NIO off-Apple).
NucleicCore (177 files) 79,600 The battleground. Exclude Container/ engine files + all MacVM/ engine files (~15.7k LOC, mirroring the Linux exclude list); shim the rest (see §4).
NucleicApp (76 files) 30,000 Do not port. SwiftUI/AppKit cockpit — this is the WinUI 3 rewrite target. Includes SwiftTerm terminal, Sparkle updater, Dock-bounce/NSSound hooks, AppleIntelligence UI.
NucleicTailnet 640 Portable Swift over #if canImport(TailscaleKit); Windows needs a libtailscale DLL leg (D9, §9.1).
NucleicMDM 2,540 Not ported (macOS-VM-only role, D4). Crypto/ASN.1 parts are portable if ever needed.
nucleicd, nucleic-smoke, nucleic-punch-harness ~1,200 Portable; nucleic-smoke becomes the Windows e2e driver (§14).
NucleicPowerHelper / NucleicPowerProtocol ~215 Not ported; replaced by SetThreadExecutionState in hostd (§4.3).
ios/NucleicRemote ~18,300 Not ported, but its Models/RemoteStore.swift (3,396 LOC, pure host-state projection) + Models/HostConnection.swift (1,294 LOC, owns SyncClient over a FrameChannel) are the structural template for the C# renderer (§7). Its Views/Transcript/ projection files are compiled into the host-testable NucleicRemoteProjection SPM target — reused by the DLL (§6).

External dependencies (from Package.resolved): GRDB 7.11 (Windows support since 7.10), swift-crypto, swift-nio(+ssl), swift-certificates/asn1, swift-log, swift-system, swift-collections etc. — all portable. SwiftTerm and Sparkle are App-target-only (replaced on Windows). third_party/containerization (Apple's framework, vendored/patched) and TailscaleKit.xcframework are Apple-only binaries.

1.3 NucleicCore internals: what needs what

Area Files (representative) Windows disposition
App engine / state AppStore.swift (14k LOC, @MainActor @Observable, conforms to SyncHostBridge) Runs headless inside hostd as the sync authority (as nucleicd proves).
Session engine SessionController.swift (2,071), Transcript.swift, TranscriptProjection Portable.
Agent backends Claude/ (9.5k), Codex/ (2.4k), Grok/ (1.2k), Backend.swift, ACP/ Portable; container-exec path only (D2).
Approvals / control plane Claude/MCPApprovalServer.swift (~3.9k), Claude/ApprovalServerRegistry.swift Portable logic; needs a NIO ByteConn (§5).
Git Git/GitRunner.swift, Git/WorktreeManager.swift (1,079), Git/GitHubCredentials.swift, Git/ProjectCloner Shells out to git/gh CLI — portable (D8). Credentials → SecretStore (§4.2).
Process spawning ProcessHost.swift (578: ProcessSpec/ProcessHandle/ChildProcess, plain pipes, no PTY), LoginShellPATH/Env Foundation Process works on Windows; signals + login-shell probing need shims (§4.3).
Persistence Persistence/GRDBMetadataStore.swift (978, WAL DatabasePool under Application Support), SessionMetadataStore protocol Portable; path mapping to %APPDATA% (§4.4).
Sync host Sync/SyncHost.swift (706), ConnectionHandler.swift (1,146), SyncHostBridge.swift (473) Portable; LAN transport files are Network.framework and already Linux-excluded — NIO rewrite (§4.3).
Containers Container/{ContainerEngine,ContainerEngine+Rootfs,ContainerManager,ContainerizedProcessHandle,MemoryBalloon,OCIArtifact,CommandInterceptor}.swift Engine = Apple-only, replaced by wslc broker (§3). Manager = policy, ported (§3.1). CommandInterceptor.swift is pure values — keep.
MacVM MacVM/ (28 files, 12.8k) Excluded (D4); keep pure value files (MacVMSpec, MacVMSurface) compiling, stub MacVMManager.
Crypto CryptoKit sites Free via existing #if canImport(CryptoKit) → swift-crypto seams.
Keychain 24 Security sites in ~14 files SecretStore protocol + DPAPI backend (§4.2).
Logging 12 os/OSLog sites Lift the Linux Logger shim into a shared file (§4.3).
On-device AI Intelligence.swift (1,295, provider seam + heuristic fallbacks), AFMRequestQueue.swift Windows Foundry provider (§9.2).

1.4 The Control/container subsystem (what wslc replaces)

Authoritative docs: docs/VSOCK_CONTROL_PLANE.md, docs/CONTAINER_ISOLATION.md, docs/NAROS.md, docs/NASH.md, docs/RUNTIME_ARCHITECTURE.md.

Two orthogonal virtualization stacks — do not conflate:

  1. The agent sandbox (Container/): Apple containerization framework, headless Linux microVMs. → This maps to wslc.
  2. Computer-use VMs (MacVM/): full Virtualization.framework GUI VMs. → Excluded.

Key mechanics that carry over unchanged (all guest-side / protocol-level, OS-agnostic):

  • Control plane protocol: host MCPApprovalServer serves HTTP JSON-RPC at POST /mcp (Streamable-HTTP MCP: initialize, ping, tools/list, tools/call with SSE for gated calls), bearer-token multiplexed per session, plus plain JSON POST /git-event, /gh-event, /command-event, /shell-event (dispatch at MCPApprovalServer.swift:1903-1916). Agent-side MCP config (mcpConfigJSON, :1611) always points at http://127.0.0.1:9099/mcp.
  • Guest bridge: containers/nucleic-sandbox/control-bridge.js listens on guest loopback 9099 (ContainerSpec.controlBridgePort) and forwards byte-for-byte to the host endpoint. On macOS that endpoint is a per-container host AF_UNIX socket relayed into the guest over virtio-vsock via Apple UnixSocketConfiguration(.into)the only macOS-specific piece of the entire control plane. A second optional forwarder (proxyBridgePort 9098) serves the opt-in Claude token proxy (Claude/ClaudeTokenProxy.swift).
  • Interceptors: Node git/gh shims installed at /usr/local/bin/{git,gh} (sources are Swift string literals: ContainerEngine+Rootfs.swift:394 and :466), exec the real binary then POST {argv, cwd, exitCode, subcommand, sessionId} to NUCLEIC_GIT_HOOK_URL/NUCLEIC_GH_HOOK_URL with a bearer token. Optional bash command tracer + batch poster in Container/CommandInterceptor.swift (also home of CommandInterceptor.hookEnv(...), the single source of interceptor endpoint env, and agentShellArgv/hostShellArgv for nash selection). The nash shell (shell/, Rust) reports /shell-event.
  • Agent run flow (Claude/ClaudeCodeBackend.swift runLoop, :766-1087; Codex/Grok backends identical shape): resolve approval server via ApprovalServerRegistrycontainerManager.ensureRunning(cspec) → mint per-session bearer token → server.start(unixSocketPath: run.container.controlSocketHostPath) → register handlers keyed by token (register, registerGitReport/GhReport/CommandReport/ShellReport, registerNucleicTools) → argv claude -p --output-format stream-json --input-format stream-json --verbose --permission-prompt-tool mcp__nucleic__approve --mcp-config <mcpConfigJSON>sandbox.exec(name:workdir:env:argv:uid:gid:) returning a ProcessHandle (Container/ContainerizedProcessHandle.swift adapts framework stdio onto the same NDJSON AsyncThrowingStream interface a host ChildProcess provides).
  • ContainerSpec (Backend.swift:54): name, image, mounts: [Mount] (host/container/readOnly), workdir, env, idleTimeout, claudeHomeStaging/claudeHomeWritable, installGitInterceptor, cpus, memoryGiB, runAsUID(=narosAgentUID 501)/runAsGID, controlSocketHostPath, proxySocketHostPath. Constants: controlSocketGuestPath = "/run/nucleic/control.sock", controlBridgePort = 9099, proxyBridgePort = 9098. Built by SessionController.containerSpec() (SessionController.swift:643) from Project.sandbox (ProjectSandbox, Project.swift:77).
  • Images: agent rootfs ghcr.io/abkslm/naros-agent:26.07 (narOS, built from os/ by .github/workflows/naros.yml; contract in docs/NAROS.md: naros-init PID 1, agent uid 501, nash as /bin/sh, NAROS_BRIDGE=1 supervises control-bridge); legacy ghcr.io/abkslm/nucleic-sandbox:v7 (containers/nucleic-sandbox/Dockerfile). The kernel OCI artifact and patched vminitd initfs are microVM concepts — NOT needed on WSL (WSL supplies kernel + init). GHCR auth: ContainerEngine.registryAuth(for:) pattern (NUCLEIC_REGISTRY_TOKEN or app GitHub token with read:packages).
  • Lifecycle policy (Container/ContainerManager.swift, 687 LOC): busy ref-counting, idle timers, shared-container naming (sharedControlContainerName, per-backend claude/codex/xai splits, channelSuffix), teardown/GC, reconcile(activeSessions:), updateAgentCLIs, checkImageUpdate, controlContainerStatus/Usage/DownloadProgress, probeControlPlane, requestControlPlaneRecovery. Its public surface is mirrored byte-for-byte by the LinuxSupport.swift stubs — it IS the port contract.

1.5 The WSL Container API (wslc) — facts

Measured, not read. Everything below is verified against the shipped assembly and a live service on Windows 11 amd64 (WSL 2.9.4) — see §13.1 for how, and for the parts that are still open. Do not trust Microsoft Learn over this section: its C# sample uses MemoryMB, CmdLine and DeleteContainerFlags, none of which exist in the shipped package.

Identity. NuGet Microsoft.WSL.Containers, version 2.9.3 — the only one published, and it tracks WSL's own version scheme. The managed assembly is lib/net8.0-windows10.0.19041.0/**wslcsdkcs.dll** (namespace Microsoft.WSL.Containers), so the package name is not the assembly name. It also ships a native wslcsdk.dll + wslcsdk.h, a .winmd for C++/WinRT, and MSBuild/CMake targets. Public preview; breaking changes possible; GA fall 2026. Requires WSL ≥ 2.9.3, which is pre-release-only (wsl --update --pre-release). CLI twin: wslc.exenot on PATH by default; it ships beside wsl.exe.

Two COM surfaces, and the SDK wraps the smaller one (§13.1, D13). wslcsdkcs.dll projects WSLCCompat.idl, the backwards-compatible SDK surface. The service-internal wslc.idl — what wslc.exe itself calls — has everything the compat surface lacks. Both are in the open-source WSL repo. The object model of the compat surface:

  • WslcService (statics): GetVersion() → ServiceVersion {Major,Minor,Revision}, GetMissingComponents() → IReadOnlyList<Component> (a list, not flags; Component = VirtualMachinePlatform | WslPackage | SdkNeedsUpdate), InstallWithDependencies(). GetMissingComponents answers from OS feature state and works even when the service class is unregistered, which makes it the call that explains every other failure.
  • Sessionnew Session(SessionSettings(name, storagePath)) then Start(). There is no CreateOrOpen. The constructor is lazy: it only captures settings, always succeeds, and is not evidence a session exists. Start() is where the service is consulted, and it refuses a name that is already running with ERROR_ALREADY_EXISTS (0x800700B7) — so the compat surface cannot re-adopt a running session. Settings: CpuCount, MemorySizeInMB (not MemoryMB), Timeout, EnableGpu, VhdRequirements. Images: PullImage / PullImageAsync (the async form carries ImageProgress through a .Progress handler), GetImagesImageInfo {Name, Sha256, Size, CreatedTimestamp}, import/load/push/tag/delete. CreateContainer(...), Terminate(), event Terminated(SessionTerminationReason). No container enumeration. Session.Authenticate(uri, user, password) → string mints the identity token PullImageOptions.RegistryAuth takes — the only producer of it (§13.2).
  • Container — created with ContainerSettings(imageName) + Name, HostName (capital N), InitProcess, and reachable only through the handle CreateContainer returned: the Container object itself exposes Id/State/InitProcess/Inspect() and no Name, so nothing can look one up (§13.2). Settings also carry NetworkingMode (ContainerNetworkingMode = None | Bridged — not NAT/mirrored), Volumes (ContainerVolume(windowsPath, containerPath, readOnly)), NamedVolumes, PortMappings, EnableAutoRemove, EnableGpu, Privileged, DomainName. Then Start(), Stop(Signal, TimeSpan), Delete(DeleteContainerOption), State, Inspect() → string, CreateProcess(...). No statistics call.
  • Processcontainer.CreateProcess(settings) then process.Start(): two steps, which is what lets handlers attach before the process runs (a single call would drop the first chunk). ProcessSettings: CommandLine (not CmdLine), EnvironmentVariables (not Environment), WorkingDirectory, OutputMode = ProcessOutputMode.Event. No uid/gid and no pty, so exec wraps argv in setpriv/su (§3.2 anticipated this) and the Terminal panel needs the internal interface. Events OutputReceived and ErrorReceived are separate (no stderr flag), plus Exited; stdin is GetInputStream(), not a write call; Signal(Signal) where Signal is a named enum limited to None, SIGHUP, SIGINT, SIGQUIT, SIGKILL, SIGTERM.
  • Errors arrive as COM HRESULTs, frequently with an empty message. The WSLC_E_* range is documented in wslc.idl (0x8004060x). Codes seen in practice: REGDB_E_CLASSNOTREG (0x80040154, nothing installed), ERROR_NOT_SUPPORTED (0x80070032, installed but too old), ERROR_ALREADY_EXISTS (0x800700B7, session name in use), E_ILLEGAL_METHOD_CALL (0x8000000E, session not started).
  • OCI images pulled directly from registries (docker.io, GHCR); multi-arch manifests resolve per host arch. GPU access supported. Interactive stdin/stdout streaming supported.
  • Service state is service-backed — containers created earlier are addressable by name from a process that never held their handle, which is the premise §2.3's broker supervision rests on.

2. Architecture

2.1 Process topology

┌─────────────────────────── MSIX package (per channel) ───────────────────────────┐
│                                                                                  │
│  Nucleic.exe (WinUI 3, C#)              nucleic-hostd.exe (Swift, full trust)    │
│  ┌───────────────────────┐   Noise/CBOR ┌──────────────────────────────────┐     │
│  │ RendererStore (C#)    │◄────────────►│ AppStore (headless) + SyncHost   │     │
│  │  ▲ P/Invoke callbacks │  loopback    │ ApprovalServerRegistry (NIO)     │     │
│  │ NucleicProtocolC.dll  │  TCP frames  │ GitRunner / WorktreeManager      │     │
│  │ (Swift, C ABI)        │              │ GRDB @ %APPDATA%\Nucleic\<chan>  │     │
│  └───────────────────────┘              │ ContainerManager (policy, Swift) │     │
│                                         │   └─ WslcBrokerClient (JSON-RPC) │     │
│                                         └───────────────┬──────────────────┘     │
│                                          NDJSON JSON-RPC │ stdio                 │
│                                         ┌────────────────▼─────────────────┐     │
│                                         │ nucleic-brokerd.exe (C#/.NET 9)  │     │
│                                         │ Microsoft.WSL.Containers (WinRT) │     │
│                                         └────────────────┬─────────────────┘     │
└──────────────────────────────────────────────────────────┼───────────────────────┘
                                                WinRT/COM  │
                                    ┌──────────────────────▼──────────────────────┐
                                    │ wslc Session "nucleic-<channel>" (WSL VM)   │
                                    │  ┌────────────────────────────────────────┐ │
                                    │  │ naros-agent containers (shared/split)  │ │
                                    │  │  control-bridge.js :9099 ──TCP──► host │ │
                                    │  │  git/gh shims, nash, claude/codex/grok │ │
                                    │  │  /work ◄─ContainerVolume─ NTFS worktree│ │
                                    │  └────────────────────────────────────────┘ │
                                    └─────────────────────────────────────────────┘

iPhone (NucleicRemote) / other renderers ──► LAN (DNS-SD + QR) / relay / tailnet ──► SyncHost
Windows app in remote-cockpit mode (D12) ──► same transports ──► any Nucleic host

2.2 Division of labor

Concern Owner Why
Sessions, transcripts, approvals, risk classification, Autoship + merge queue, LockManager, nvrsion, quotas, idea inbox, activity/streaks Swift hostd (existing NucleicCore, logic unchanged) Maximum Swift reuse (D1).
Container lifecycle policy (refcounts, idle, shared naming, reconcile, CLI updates) Swift hostd (ContainerManager, retargeted onto SandboxEngine) Policy stays single-sourced across macOS/Windows.
Container mechanism (wslc Session, image pull, create/exec/stdio/signals) C# nucleic-brokerd wslc API is WinRT; Microsoft recommends C# (D3).
Wire protocol (CBOR, Noise, envelope, transcript projection) Swift NucleicProtocolC.dll Single-sourced wire format (D7).
UI, toasts, taskbar flash, theming, Phi Silica calls C# Nucleic.exe WinUI 3 mandate.

2.3 Broker process model

Separate exe, child of hostd, NDJSON JSON-RPC 2.0 over stdio. Rationale:

  • Containers must outlive the UI window; broker lifetime must match hostd, not Nucleic.exe.
  • hostd already has everything needed to run it: ChildProcess (Sources/NucleicCore/ProcessHost.swift) for spawn + NDJSON line streams, and Sources/NucleicCore/JSONRPCConnection.swift for framing. Zero new IPC surface — no named-pipe ACLs, no COM registration.
  • Crash containment: wslc is preview software. hostd supervises brokerd with exponential-backoff restart. On reattach, brokerd re-opens the named wslc Session and re-enumerates containers (wslc state is service-backed); hostd then runs ContainerManager.reconcile(activeSessions:) against the fresh listing. In-flight execs fail with a distinguishable brokerLost error, mapped to the same recovery path ContainerizedProcessHandle.forceCloseStreams() serves today.
  • Broker sends a capabilities hello (wslc/NuGet version, supported features) so hostd can degrade gracefully across preview→GA churn.

2.4 Lifetime & single-instance

  • Nucleic.exe launches hostd at startup if not running. Single-instance guard: named mutex Local\nucleic-hostd-<channel> (hostd also self-guards).
  • hostd writes a rendezvous file %LOCALAPPDATA%\Nucleic\<channel>\hostd.json = {pid, port, localPSK} with an owner-only DACL. The renderer (and nucleic-smoke) reads it to connect. Stale detection: pid liveness + connect probe.
  • hostd outlives the renderer window while any session is active or the Autoship queue is non-empty; the renderer exposes explicit "Quit host". Clean shutdown via SetConsoleCtrlHandler (CTRL_CLOSE/LOGOFF/SHUTDOWN) routed into the same shutdown path nucleicd drives from SIGTERM (Nucleicd.swift:255-263 gets a Windows leg).
  • MSIX declares runFullTrust; hostd + brokerd + DLL + Swift runtime + MinGit ship in the package payload.

3. Container subsystem port

3.1 The SandboxEngine seam (Darwin-first refactor)

New file Sources/NucleicCore/Container/SandboxEngine.swift — a platform-neutral protocol capturing exactly the mechanism surface ContainerManager and the backends use today (extracted from ContainerEngine.swift:513, :660, :697, :727-:924):

public protocol SandboxEngine: Sendable {
    /// Idempotent create-or-start; returns the container name and the address at which
    /// the guest can reach the host (macOS: vmnet gateway; Windows: WSL vEthernet gateway).
    func ensureRunning(_ spec: ContainerSpec) async throws -> (name: String, hostGateway: String)
    func exec(name: String, workdir: String, env: [String: String],
              argv: [String], uid: UInt32, gid: UInt32) async throws -> any ProcessHandle
    func runCapturing(name: String, workdir: String, env: [String: String],
                      argv: [String]) async -> (exitCode: Int32, output: String)?
    func stop(name: String) async
    func restart(name: String) async -> Bool
    func remove(name: String) async -> Bool
    func isRunning(name: String) async -> Bool
    func containerExists(name: String) async -> Bool
    func list() async -> [ControlContainerEntry]
    func sampleResourceUsage(name: String) async -> ContainerResourceSample?
    func probeControlPlane(name: String) async -> ControlPlaneProbe
    func diagnoseKill(name: String) async -> String?
    func reclaimMemoryNow() async
    func pullImage(_ ref: String) async throws     // progress via existing download-progress plumbing
    func checkImageUpdate(_ ref: String) async -> Bool
    // NOTE: match the real ContainerEngine signatures exactly when implementing — the
    // list above is the shape; lift precise signatures from ContainerEngine.swift.
}

Steps (this lands Darwin-first and is verified by the existing macOS test suite before any Windows code exists):

  1. Add the protocol; conform the Apple ContainerEngine (mechanical).
  2. Retype ContainerManager's engine reference (and the few direct engine references in ClaudeCodeBackend / CodexAppServerBackend / CodexExecBackend / GrokACPBackend) to any SandboxEngine.
  3. Keep LinuxSupport.swift stubs compiling (Linux/nucleicd behavior unchanged — the stub ContainerManager remains the Linux story).
  4. On Windows, ContainerManager.swift moves off the exclude list and compiles against the protocol. Engine files (ContainerEngine*.swift, ContainerizedProcessHandle.swift, MemoryBalloon.swift) stay excluded. Audit OCIArtifact.swift: pure ref-parsing/version helpers move to a shared file if WslcContainerEngine wants them.

3.2 WslcContainerEngine mapping

New files in Sources/NucleicCore/Container/Windows/ (compiled only on Windows):

  • WslcBrokerClient.swift — spawns nucleic-brokerd.exe via ChildProcess, speaks NDJSON JSON-RPC over its stdio, multiplexes requests, supervises (backoff restart, capabilities hello, reattach → notify ContainerManager.reconcile).
  • WslcContainerEngine.swiftSandboxEngine conformance over the broker RPC.
  • WslcProcessHandle.swift — mirrors ContainerizedProcessHandle.swift: adapts proc.stdout/stderr/exit notifications onto the ProcessHandle protocol (ProcessHost.swift:44-70): stdoutLines/stderrLines as AsyncThrowingStream<Data, Error> through the shared LineSplitter, writeLine framing NDJSON to stdin, wait() → exit code, sendSignal → broker proc.signal, forceCloseStreams() on broker loss, lastActivityNanos bumping, supportsHostCPUSampling = false.

Concept mapping:

Nucleic concept (macOS) wslc equivalent
One microVM per container (Apple containerization) One wslc Session per channel (new SessionSettings("nucleic-<channel>", storagePath: %LOCALAPPDATA%\Nucleic\<channel>\wslc), CpuCount/MemorySizeInMB from settings, then Start()) hosting all Nucleic containers as siblings.
ContainerSpec.name/image ContainerSettings(image) + .Name (keep existing naming statics incl. channelSuffix so dev/canary/stable coexist).
spec.mounts (host worktree → guest path) ContainerVolume { hostPath: NTFS path, guestPath: mount.container, readOnly }.
spec.cpus/memoryGiB Session-level CpuCount/MemorySizeInMB (wslc resources are per-session). Per-container limits: cgroup exec inside the container if needed later.
Memory balloon (MemoryBalloon.swift) No per-container balloon. reclaimMemoryNow() → WSL autoreclaim / drop-caches exec; SessionSettings.MemorySizeInMB resize applies after sandbox restart — surface in Settings exactly like the existing restart-shared flow.
sampleResourceUsageContainerResourceSample No GetStatistics() on any wslc surface the broker can reach, so container.stats execs a cgroup v2 read in the guest (cat /sys/fs/cgroup/{cpu.stat,memory.current,memory.max,memory.events}) and parses it broker-side — cat/echo only, since the image is the user's. memory.max reads the literal max when unlimited, which must become "unknown" and not 0, or a usage percentage reads as "no memory allowed".
runAsUID: 501 Settled by M1 (a): ProcessSettings has no uid/gid, so exec wraps argv in setpriv --reuid=<uid> --regid=<gid> --init-groups -- (implemented) — the fallback §3.2 always named, with su agent -c as the alternative if an image lacks util-linux. Interceptors and nash don't care about the numeric uid.
Rootfs pull + ext4 clone (+Rootfs.swift) session.PullImageAsync(new PullImageOptions(ref)), progress via the returned operation's .Progress handler (ImageProgress) → controlDownloadProgress plumbing; RegistryAuth is a plain string, minted by session.Authenticate(registryUri, user, token) (§13.2). No kernel/vminitd artifacts.
Shim seeding (seed(_:in:), base64 install) Prefer baking into the multi-arch narOS image (already the case); keep idempotent re-seed via runCapturing for legacy-image support.
ensureRunning returns hostGateway No API surfaces one (§13.1), so the broker derives it from GetAdaptersAddresses over vEthernet (WSL) — implemented in WslcFacade via NetworkInterface, the managed wrapper over that call, polled because the vNIC appears as the VM boots. The hvsocket alternative is not available: IWSLCVirtualMachine is unreachable from a client, so there is no VMID (§13.2). M1 (b) confirmed it reachable from a container under the default firewall (§13.3).

3.3 Broker RPC surface (windows/NucleicBroker)

NDJSON JSON-RPC 2.0 over stdio; binary payloads base64. Versioned hello first.

Requests (hostd → broker):

  • hello() → {brokerVersion, wslcVersion, capabilities: [..]}
  • components.missing() → {flags} / components.install() (+ progress notifications; wraps WslcService.GetMissingComponents / install — feeds onboarding, §8)
  • session.ensure({name, dataDir, cpu, memoryMB}) → {gateway} / session.terminate()
  • image.pull({ref, auth}) (+ image.pullProgress {ref, status, current, total}), image.list / image.delete / image.inspect
  • container.create({name, image, volumes:[{host,guest,ro}], networkingMode, hostname, env, initArgv})
  • container.start / container.stop({name, signal, graceMs}) / container.delete / container.list / container.state / container.stats({name})
  • proc.exec({container, argv, env, cwd, uid, gid, tty}) → {procId}
  • proc.stdin({procId, b64}) / proc.closeStdin({procId}) / proc.signal({procId, sig})
  • proc.resize({procId, cols, rows}) (tty mode, for the Terminal panel)
  • ai.generate({prompt, schema}) (§9.2 — Phi Silica sidecar duty)

Notifications (broker → hostd):

  • proc.stdout {procId, b64} / proc.stderr {procId, b64} / proc.exit {procId, code}
  • session.down {reason} (from SessionTerminationHandler)
  • image.pullProgress, components.installProgress

Broker implementation notes: .NET 9, single-file publish per arch; all wslc calls behind an internal IWslc interface so unit tests run against a fake; version-pin the NuGet; map ProcessOutputMode.Eventproc.stdout notifications (OutputReceived and ErrorReceived are two separate events, so subscribe twice); batch/flush stdout notifications (agents emit high-rate NDJSON — coalesce writes, never block the WinRT event thread).

Which surface serves which RPC (D13, §13.1). Four of the calls above have no compat-SDK implementation and must come from the internal COM interface:

RPC Served by
container.list, and the reattach roster behind container.state IWSLCSession::ListContainers / OpenContainer. Today: the broker's own name→handle roster, because Container has no Name and Session cannot enumerate (§13.2) — correct within one broker lifetime, empty after a restart.
container.stats IWSLCContainer::Stats (JSON)now served by the cgroup read via exec (§13.2), so this row no longer needs the internal arm at all
proc.resize (and proc.exec with tty: true) IWSLCProcess::ResizeTty; the compat surface has no pty at all. Today: unsupported, with tty absent from the hello capabilities.
session reattach on broker restart IWSLCSessionManager::OpenSessionByName / EnterSession — confirmed necessary on hardware, since the compat Session constructor is lazy and its Start() refuses an existing name. Today: session.ensure fails session_exists, naming wsl --shutdown as the manual remedy.

Entry point confirmed on hardware: CoCreateInstance(WSLCCompatSessionManager, a9b7a1b9-0671-405c-95f1-e0612cb4ce8f) then QueryInterface(IWSLCSessionManager, 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760). wslc.idl names no coclass, so this pairing is the thing to remember. See §13.2.

Everything else — session.ensure/terminate, image.*, container.create/start/stop/ delete, proc.exec/stdin/signal and the event stdio — rides the compat SDK.


4. NucleicCore on Windows: shims & seams

4.1 Package.swift changes

  • Add onWindows (mirror the onLinux detection at Package.swift:62-64); introduce nonDarwin = onLinux || onWindows and use it wherever onLinux currently gates darwinOnlyTargets, Sparkle, and product lists (:70, :219, :266, :402).
  • Widen NucleicProtocol's swift-nio / swift-crypto dependency conditions from .linux to [.linux, .windows].
  • NucleicCore exclude: for Windows = the Linux list (:352-381) minus Container/ContainerManager.swift (now protocol-typed, compiles on Windows), keeping excluded: Container/ContainerEngine.swift, Container/ContainerEngine+Rootfs.swift, Container/ContainerizedProcessHandle.swift, Container/MemoryBalloon.swift, all MacVM/*Engine*/MacVMManager/MacVMExecChannel/MacVMAgentClient, and the Network.framework Sync files. Container/CommandInterceptor.swift, MacVMSpec.swift, MacVMSurface.swift stay in (pure values).
  • Add Sources/NucleicCore/Windows/** and Sources/NucleicCore/Container/Windows/** (excluded on non-Windows), the NucleicProtocolC dynamic-library target/product, and the nucleic-hostd executable target.
  • nucleicd is excluded from the Windows manifest (nonWindowsTargets / nonWindowsProducts, item 9). It is the Covalence runner host; Windows has nucleic-hostd, and ControlEndpoint.swift is a BSD-socket listener whose libc ladder has no Windows arm, with a DispatchSource signal handler in Nucleicd.swift. Since swift test builds every declared target, leaving it in would mean porting a daemon Windows will never run.
  • SwiftPM remains the build system, including the DLL (.library(type: .dynamic)). If @_cdecl symbol auto-export proves unreliable, pass a checked-in Sources/NucleicProtocolC/exports.def via -Xlinker /DEF:. CMake is the documented fallback, not the plan.

4.2 SecretStore (Keychain replacement)

Introduce protocol SecretStore (get/set/delete/list by service+account, Data values) in NucleicCore; macOS backend wraps the existing Keychain calls; Windows backend Sources/NucleicCore/Windows/CredentialStore.swift uses DPAPI (CryptProtectData/CryptUnprotectData, user scope, CRYPTPROTECT_UI_FORBIDDEN) encrypting per-item files under NucleicPaths.secretsDirectory with owner-only DACLs. DPAPI chosen over Windows Credential Manager because of Credential Manager's ~2.5 KB blob limit vs. Nucleic's larger OAuth token sets.

Path note (item 9): the secrets dir is %APPDATA%\Nucleic<channel>\Secrets, not the %LOCALAPPDATA%\Nucleic\secrets this section originally sketched — it hangs off the single NucleicPaths.dataRoot with everything else the host owns, so one NUCLEIC_DATA_DIR moves the store, the secrets, the run dir and the logs together. The interim file backend (LinuxSecretStore, shared with Linux per item 3) already writes there with an owner-only DACL via SecretFile; the DPAPI layer adds encryption at rest on top of the same path.

Retarget the ~24 Security call sites (~14 files) onto the protocol; macOS behavior unchanged. Files: KeychainOwnedAccess.swift, Claude/ClaudeLoginKeychain.swift, Claude/ClaudeCredentialBroker.swift, Codex/CodexAuthFile.swift, Codex/CodexCredentialBroker.swift, Carbon/CarbonKeyCustody.swift, Carbon/CarbonCrypto.swift, Git/GitHubCredentials.swift, HeartbeatReporter.swift, ControlAuth.swift, SubscriptionUsage.swift, Sync/HostIdentityStore.swift, Sync/CredentialProvider.swift, Sync/RunnerCredentialVault.swift, Sync/PushRelayClient.swift, Sync/RelayAccess.swift, Sync/RunnerPoolClient.swift (grep canImport(Security) for the authoritative list; MacVM/MacVMEngine+MDM.swift is excluded anyway). Land Darwin-first like the SandboxEngine seam.

4.3 Windows platform shims (Sources/NucleicCore/Windows/)

File Replaces Implementation
WindowsSupport.swift LinuxSupport.swift role Stub MacVMManager (and anything else the Linux file stubs that Windows also lacks). Lift the os.Logger shim out of LinuxSupport.swift:13-54 into a shared PortableLogging.swift compiled on both Linux and Windows (swift-log backend; rolling files at %LOCALAPPDATA%\Nucleic\logs, ETW optional later).
NIOByteConn.swift NWByteConn (Network.framework) in MCPApprovalServer SwiftNIO TCP listener behind the existing ByteConn abstraction; start(host:port:) keeps its signature; AF_UNIX leg goes #if !os(Windows). Also usable on Linux later.
NIOLANTransport.swift / NIOLANListener.swift Sync/LANTransport.swift, LANBrowser.swift, PeerDialer.swift, MeshPathMonitor.swift (Network.framework files, already Linux-excluded) FrameChannel + listener over NIO; path monitoring degraded to periodic reachability probes (no NWPathMonitor equivalent — acceptable).
WindowsDNSSD.swift Bonjour advertise/browse dnsapi.dll DnsServiceRegister/DnsServiceBrowse/DnsServiceResolve (WinSDK module) for _nucleic._tcp with the pairing TXT payload. Fallback documented: in-process NIO mDNS responder if dnsapi TXT limits bite.
WindowsSignals.swift [landed] pid_t, SIGKILL/SIGTERM/SIGINT (ucrt has no SIGKILL and no kill) Module-internal stand-in declarations, so ~30 shared call sites keep their POSIX spelling. In a file that does NOT import WinSDK, so it can't collide with ucrt's own SIGINT/SIGTERM.
ProcessTree.swift [landed] POSIX signal semantics in ChildProcess.sendSignal; the hostProcessTable/CPU/IO samplers Assign every host child to a Job Object at spawn (CreateJobObjectW + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, attached right after Process.run() — corelibs exposes no creation flags). SIGKILL → TerminateJobObject/TerminateProcess with exit code 137, so Backend.isSIGKILL reads a killed host command the same on every platform; SIGTERM/SIGINT → GenerateConsoleCtrlEvent, then job-terminate after grace. Process table via Toolhelp, CPU via GetProcessTimes, I/O via GetProcessIoCounters. (Container processes get real POSIX signals via wslc — no shim needed there.)
FilePermissions.swift [landed] chmod 0600 / posixPermissions hardening (~12 sites: Carbon/CarbonKeyCustody.swift, Claude/ClaudeLoginKeychain.swift, SessionController.swift, Codex/{CodexCredentialBroker,CodexAuthFile}.swift, Sync/{CredentialProvider,RunnerCredentialVault}.swift, Git/GitHubCredentials.swift, LinuxSupport.swift's file secret store) SetEntriesInAclW + SetNamedSecurityInfoW protected single-ACE DACL, behind the shared SecretFile (createDirectory / write / restrictToOwner / createEmptyFile) that both platforms call. The unix-socket chmods in ClaudeTokenProxy/MCPApprovalServer are NOT retargeted — those paths don't exist on Windows.
FileWatcher.swift [not needed] The DispatchSource uses attributed here to file watching are socket accept sources (MCPApprovalServer, ClaudeTokenProxy), and both already sit behind platform legs — NIO on Windows, Darwin-only respectively. Nothing in NucleicCore watches a file with DispatchSource.
PowerBlocker.swift [landed] NucleicPowerHelper + SleepBlocker SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) held while a turn is in flight; released on idle and at shutdown. Driven from a 5-second main-actor tick in hostd over store.localTurnInFlightCount (headless has no onChange); one thread throughout, because the state dies with its thread. Default-on (see the item-9 status note), same nucleic.power.blockSleep key as the Mac.
WindowsEnvironment.swift [landed] — the LoginShellEnv / LoginShellPATH Windows legs POSIX login-shell probing; setenv/unsetenv No shell probe: a Windows process is born with the merged machine+user environment, so LoginShellEnv.resolve returns nil. LoginShellPATH instead reads HKLM\…\Session Manager\Environment + HKCU\Environment (an installer's PATH edit is invisible to a running host otherwise) and splits/joins on ; — splitting a Windows PATH on : shreds every C:\… entry. Also supplies setenv/unsetenv over SetEnvironmentVariableW.
WindowsFileIO.swift [landed] write temp → fsync → rename(2) (CarbonShardStore) MoveFileExW(REPLACE_EXISTING | WRITE_THROUGH). The CRT's rename fails when the destination exists, which for a content-addressed store would turn every idempotent re-write into an error; WRITE_THROUGH buys back what fsync did.
WindowsSupport.swift [landed] /usr/bin/env PATH resolution in ChildProcess windowsResolveExecutable — PATH × PATHEXT, against the same widened PATH the child will get. Process resolves executableURL against the cwd, so a bare git would otherwise spawn <cwd>\git.

4.4 Storage & paths

  • corelibs-foundation maps .applicationSupportDirectory%APPDATA%. Data root: %APPDATA%\Nucleic\<channel>\ (channel-suffixed so all five channels coexist — mirrors the container channelSuffix scheme). GRDB WAL DatabasePool (Persistence/GRDBMetadataStore.swift:13 and the other Application-Support call sites: KeychainOwnedAccess.swift:177, TailnetTransport.swift, Claude/ApprovalServerRegistry.swift, Cast/MeshDebugLog.swift) get a single NucleicPaths helper rather than N inline lookups. [landed, item 9]NucleicPaths.dataRoot / .runDirectory / .logsDirectory / .secretsDirectory, with NUCLEIC_DATA_DIR overriding all of them. macOS/Linux resolve to exactly the previous literal (<Application Support>/Nucleic), so no installed path moves; only Windows takes the channel-suffixed %APPDATA% branch. nucleicd's runner root (NUCLEIC_RUNNER_DATA_DIRNucleicRunner) is deliberately NOT folded in — it is a different root by design.
  • Control repos: %USERPROFILE%\.nucleic\control\<project>\ with worktrees at <repo>\.nucleic\worktrees\<slug> (unchanged layout).
  • Approval-server socket paths (/tmp/nucleic/<name>.sock) become (gatewayIP, port) tuples behind the existing ApprovalServerRegistry API on Windows (§5).
  • Git: bundle MinGit (x64/arm64) in the MSIX; configure managed clones with core.longpaths=true and core.autocrlf=false (agents run in Linux containers — the repo must stay LF; .gitattributes-driven projects behave as configured). GitRunner/WorktreeManager code unchanged.

5. Control plane on Windows

Everything above the transport is reused: MCPApprovalServer routes + SSE + bearer-token multiplexing, ApprovalServerRegistry, interceptor shims, control-bridge.js, CommandInterceptor.hookEnv, mcpConfigJSON — all unchanged. Only the host↔guest byte path is replaced.

Primary: TCP to the WSL vEthernet gateway (smallest change that works).

  1. MCPApprovalServer.start(host:port:) gains the NIOByteConn transport (§4.3), bound only to the WSL-facing interface address (never 0.0.0.0).
  2. WslcContainerEngine.ensureRunning returns the gateway address the broker reports — this is exactly the hostGateway value the macOS engine already returns, so the contract doesn't change.
  3. control-bridge.js gets a small additive branch: when NUCLEIC_CONTROL_HOST / NUCLEIC_CONTROL_PORT env are present, forward guest-loopback 9099 to that TCP endpoint instead of the relayed UDS (keep the UDS branch for macOS). Same for the proxy bridge (9098NUCLEIC_PROXY_HOST/PORT).
  4. Networking-mode handling: NAT (default) → gateway routing as above. Mirrored mode (Win11 22H2+) → broker detects and reports the reachable host address instead. Auto-detect; prefer NAT for determinism.
  5. Firewall: MSIX can't install rules; rely on Windows 11's Hyper-V firewall defaults (guest→host on the WSL vSwitch) and ship an onboarding connectivity self-test surfaced through the existing probeControlPlane seam, with an elevated one-shot New-NetFirewallRule remediation script if the default-deny case is hit.

Superseded in part by D13 (§13.1). IWSLCVirtualMachine::GetId supplies the VM GUID an AF_HYPERV bind needs — the missing piece that made hvsocket "upside" below. The ordering should invert at M1 (b): hvsocket primary, gateway TCP fallback. Note also that ContainerNetworkingMode is None | Bridged, not the NAT/mirrored pair item 4 assumes, and no API surfaces a gateway address (it comes from GetAdaptersAddresses over vEthernet (WSL)).

Spike + fallback: Hyper-V sockets (true vsock parity). Host listens on AF_HYPERV bound to the WSL VM's VMID + a service GUID derived from the logical port; guest dials AF_VSOCK CID 2. No firewall exposure, no routing variance. Unknown: whether wslc containers pass vsock through their namespaces (likely — vsock isn't network-namespaced). M1 answers this; promote to primary if gateway-TCP shows friction in the wild (enterprise firewall policy, DNS tunneling interactions).

Rejected: named-pipe-via-WSL-interop (pipes aren't visible inside wslc containers without brittle 9P mounts).

Security note: the bearer token — not the transport — is the auth boundary today (the vsock relay was defense-in-depth). Interface-scoped binding restores equivalent posture; tokens remain per-session, minted in runLoop.


6. NucleicProtocolC.dll (renderer protocol client)

New target Sources/NucleicProtocolC/, depending on NucleicProtocol + NucleicRemoteProjection (the SPM target that already compiles ios/.../Views/Transcript/{TranscriptProjection,IncrementalTranscriptProjection}.swift for host testing — reuse it, don't duplicate).

Flat C ABI via @_cdecl (UTF-8 JSON strings in/out; caller frees with np_free; header at Sources/NucleicProtocolC/include/nucleic_protocol.h):

typedef void (*np_event_cb)(void* ctx, const char* host_msg_json);
typedef void (*np_state_cb)(void* ctx, const char* state_json); // connect/disconnect/transport/errors

np_handle*  np_client_create(const char* config_json);   // identity dir, host key pins
void        np_client_set_callbacks(np_handle*, np_event_cb, np_state_cb, void* ctx);
int         np_client_connect_local(np_handle*, const char* rendezvous_json); // {host,port,psk}
int         np_client_connect_remote(np_handle*, const char* endpoint_json);  // D12: LAN/relay/tailnet
int         np_client_pair(np_handle*, const char* pairing_code);
int         np_client_send_intent(np_handle*, const char* client_msg_json);
void        np_client_close(np_handle*);
void        np_free(const char*);

np_proj*    np_projection_create(void);
const char* np_projection_apply(np_proj*, const char* host_msg_json); // incremental render-diff JSON
void        np_projection_free(np_proj*);

Internals: a NIO loopback/TCP FrameChannel implementation feeding the unchanged SyncClient (NucleicProtocol/Sync/SyncClient.swift is generic over FrameChannel — no changes). Noise runs even locally (one code path for local + remote peers; the rendezvous PSK auto-approves local pairing, §2.4). Callbacks fire from one dedicated DLL thread; C# marshals to its dispatcher.

The integer return is synchronous admission, not connect success: 0 means the request was validated and queued; -1 invalid argument, -2 invalid handle state, -3 malformed JSON, and -4 unsupported transport. State JSON then reports connecting, transport, ready, failed, and closed. A ready document carries the decoded Welcome, learned host key, and canonical host id. Event JSON is always one canonical HostMsg.

Projection output is a versioned splice contract:

{"version":1,"sessionID":"…","splices":[
  {"start":12,"deleteCount":2,"items":[{"id":"…","seq":"42","type":"message", "...":"…"}]}
]}

The renderer applies splices in order to its ItemsRepeater source. Empty splices is an idempotent/no-projection-change result. seq is deliberately a decimal string: it is a wire UInt64, and routing it through a JSON floating-point representation would silently lose high bits.

The one real work item: JSON encode/decode for ClientMsg/HostMsg at the ABI boundary (they are CBOR-coded on the wire). Landed as NucleicProtocol/Sync/ProtocolJSON.swift rather than in the DLL — see the item-10 note in the execution status for why, and for the list of coder decisions it pins. Lock it with golden fixtures: fixtures/protocol-abi/ holds paired CBOR↔JSON vectors for every message kind. Swift's ProtocolABIFixtureTests validates them on every wire-layer platform; NucleicProtocolCTests validates the actual C veneer on Linux. The C# half (xUnit in windows/NucleicApp.Tests) joins in item 11, so both language views then gate every change. The envelope already carries protocol versioning — bump fixtures with it.


7. WinUI 3 renderer (windows/NucleicApp)

Stack: .NET 9, Windows App SDK 1.7+, WinUI 3, CommunityToolkit.Mvvm + CommunityToolkit.WinUI (SettingsControls; Markdown via Labs MarkdownTextBlock with RichTextBlock fallback), WebView2 (Monaco) for diff/editor, Windows Terminal control (Microsoft.Terminal.Control) for the terminal panel.

Layering mirrors ios/NucleicRemote exactly — study Models/RemoteStore.swift (a pure projection of host state; the wire types ARE the models) and Models/HostConnection.swift (owns the SyncClient, handles pairing modes and transport failover) before writing C#:

  • Interop/ — P/Invoke bindings for nucleic_protocol.h; HostMsg/ClientMsg DTOs via System.Text.Json source generators (AOT-friendly, fast).
  • Services/HostConnection.cs — DLL handle lifecycle, reconnect loop, rendezvous-file watch; multi-host (D12): one instance per paired host, local host auto-added.
  • Services/RendererStore.cs — the RemoteStore analog: ObservableObject projection (sessions, projects, approvals, dashboard, quotas, activity, todos, mesh state), fed exclusively by HostMsg events; all mutations are intents (np_client_send_intent). No canonical state invented in the renderer, ever.
  • Services/HostdLauncher.cs — mutex check, spawn hostd, health probe.
  • Services/OnboardingService.cs — wslc component flow (renderer → hostd intent → broker components.*), progress UI, connectivity self-test display.

Screens (parity targets from Sources/NucleicApp and ios/.../Views):

Screen macOS source (reference) Notes
Shell / navigation RootView.swift (1,681) NavigationView split layout; host picker at top (D12).
Home HomeView.swift, activity grid, streaks, idea inbox (TodoView.swift) Pure projections — data comes from host.
Project view / Control panel ProjectView.swift, ControlPanelView.swift Container status/usage cards use controlContainerStatus/Usage wire data.
Session detail + transcript SessionDetailView.swift (2,407), TranscriptRow.swift (2,343), HostExecCard.swift ItemsRepeater with incremental virtualization driven by np_projection_apply diffs; tool rows color-coded by risk; collapsible thinking.
Composer NewChatComposer.swift, ChatInputField.swift, ComposerAttachments.swift Model/effort/Orchestra picker (SKU-routed backends), attachments.
Approvals approval cards in session view; ApprovalCardView (iOS) Allow / deny / modify-and-allow / always-allow-in-session; first-responder-wins collapse on approvalResolved.
Terminal panel Panels/TerminalPanel.swift (SwiftTerm) In-container shell (D2): hostd intent → broker proc.exec(tty:true) of agentShellArgv (nash) in the session container; bytes ride a per-terminal loopback side-channel vended by hostd; rendered in the Windows Terminal control; proc.resize on layout. No host ConPTY needed.
Editor / diff Panels/EditorPanel.swift, SyntaxHighlighting.swift, diff tab Monaco in WebView2 (read-only diff-first); native later if desired.
Build & Run BuildRunPanel.swift Host process spawn via hostd intent (ChildProcess + Job Objects).
Settings SettingsView.swift (2,995), RemoteAccessView.swift, ModelCatalog.swift, QuotaIndicator.swift Sections that apply: agents/accounts (OAuth via loopback listener), sandbox (CPU/memory, restart-shared), remote access (QR pairing display, transports), updates (channel display; App Installer drives updates).
Attention DockBounce.swift, sound hooks (AppStore.swift:668-681) AppNotificationManager toasts + FlashWindowEx + optional sound, wired to the same AppStore hook events over the wire.
Accessibility/theming 4 color-vision palettes + 5 text sizes (app-wide) Resource dictionaries keyed off host-synced settings (SyncedSettings.swift); honor Windows high-contrast.

8. Onboarding flow (first run)

  1. Renderer launches → HostdLauncher starts hostd → hostd starts broker → broker components.missing().
  2. If WSL/wslc components missing → guided install page (elevated wsl --install / component install via broker), progress streamed, reboot handling.
  3. Broker session.ensureimage.pull naros-agent (progress → existing controlDownloadProgress UI path).
  4. Control-plane connectivity self-test (probeControlPlane) with actionable remediation (firewall script) on failure.
  5. Agent CLI auth: Claude/Codex/Grok OAuth flows (loopback listener, DPAPI custody) — same UX as macOS Settings.
  6. Add or clone a Control project → first session.

9. Parity gap plans

9.1 Tailnet (D9)

  • Build libtailscale as tailscale.dll (Go -buildmode=c-shared) for windows/amd64 + windows/arm64; new windows/scripts/build-libtailscale-windows.ps1 pinned to the same commit as scripts/build-tailscalekit.sh.
  • Sources/NucleicTailnet gains a Windows leg over the same libtailscale C header that TailscaleKit wraps (#if canImport(TailscaleKit) stays Apple-only; add #if os(Windows) + dlopen-or-link of the DLL). The fd-backed FrameChannel transport works over Winsock fds.
  • Absent DLL → existing isBuiltIn == false path (feature shows "not built in").

9.2 On-device AI (D6)

  • Intelligence.swift already has a provider seam with heuristic fallbacks. Add a WindowsFoundryIntelligenceProvider: Phi Silica is WinRT-only, so hostd routes requests to a C# sidecar — reuse brokerd with the ai.generate RPC (keeps one helper process). Capability-probe at startup; fall back to heuristics when unavailable (non-Copilot+ hardware) — identical UX to a Mac without Apple Intelligence.

9.3 Free-by-construction (verify, don't rebuild)

LockManager, ConflictCoordinator, nvrsion, Autoship + merge queue, idea inbox, activity/ streaks, quota gauges (SubscriptionUsage.swift logic), Cast/Mirror, transfer — all pure core + GRDB; they work once NucleicCore compiles and DPAPI custody lands. Cover via the existing cross-platform test suites plus the M2 e2e.

9.4a Deferred is not excluded — read this before writing "unsupported"

Two different things get written up the same way and must not be:

  • Excluded by decision (§9.4 below): the MacVM computer-use subsystem, NucleicMDM, NucleicPowerHelper, Sparkle, SwiftTerm. These are D4 calls about what the Windows product is.
  • Deferred until it can no longer be avoided: everything else that currently answers unsupported, degrades, or is missing a capability flag. These are sequencing calls, not product ones, and each has a known route:
Deferred Route when it becomes unavoidable Needed by
pty / proc.exec(tty:true) / proc.resize IWSLCProcess::ResizeTty + CreateRootNamespaceProcess on the internal COM arm (D13) item 12's Terminal panel, M4
D13 Tier 2 "adopt" — containers survive a broker restart internal-COM exec/stdio (IWSLCContainer::Exec, IWSLCProcess::GetStdHandle), since §13.2 found the compat projection cannot wrap a service-side handle nothing before M4; Tier 1 covers M2
service-wide container.list (enumerate) IWSLCSession::ListContainers, already written and proven for recovery (§13.3) — it just is not wired into the live roster a fuller reconcile than M2 needs
signals outside wslc's six (SIGUSR1, SIGWINCH) no route on either surface today; would need Microsoft to widen WSLCSignal nothing known
uid drop on a non-util-linux image a su-based fallback, or documenting util-linux as an image requirement only custom images; narOS ships it

So WslcError.Unsupported means "this build does not serve that call, and retrying will not help" — a signal about retry semantics, not a claim about the product. When one of these is implemented, the kind stops being returned and the capabilities hello gains a flag; nothing above IWslc changes. Write new gaps up the same way: what it would take, and what needs it.

9.4 Explicitly out of scope (D4)

MacVM/ computer-use (mac_vm_* and GUI linux_vm_* tools stay unadvertised — they're per-token-registered, so simply don't register handlers), NucleicMDM server role, NucleicPowerHelper, Sparkle, SwiftTerm, shell/ nash host-side use (nash still ships inside the containers and is fully used there).

9.5 nvrsion pre-land hooks — deferred to the nash port

NvrsionTrunk.runPrelandHook runs a trunk's pre-land hook as /bin/sh -c <command>; hooks are written as sh one-liners and Windows has no sh. cmd.exe was rejected as a stand-in — it would run those commands with different semantics, silently, at the moment the hook exists to be a safety gate — and Git-for-Windows bash.exe was rejected as a second shell to depend on when nash is the intended answer.

So on Windows a configured hook rejects the land with a message naming the reason (the same outcome the POSIX path gives for a hook that can't start, minus the misleading "could not start" text). A trunk with no hook configured is unaffected: land(...) only calls the hook when prelandHook is non-nil. Revisit when nash ports to Windows and can execute these directly.


10. Repo layout for new code

windows/                          # sibling of ios/ and cloud/
  Nucleic.sln
  NucleicApp/                     # WinUI 3 app: Views/ Services/ Interop/ Assets/
  NucleicApp.Package/             # MSIX packaging project (per-channel manifests templated)
  NucleicBroker/                  # nucleic-brokerd (Microsoft.WSL.Containers + ai.generate)
  NucleicProtocol.Interop/        # shared P/Invoke + DTO library (app + tests)
  NucleicApp.Tests/               # xUnit incl. protocol-abi golden fixtures
  NucleicBroker.Tests/            # broker contract tests against fake IWslc
  spikes/                         # M1 spike programs (kept, runnable)
  appinstaller/                   # nucleic-<channel>.appinstaller.template
  props/Directory.Build.props     # TFM, versions, channel from VERSION/NUCLEIC_CHANNEL
  scripts/{build-hostd.ps1, package-msix.ps1, release-windows.ps1,
           build-libtailscale-windows.ps1}
Sources/
  nucleic-hostd/                  # Windows headless host (shares bring-up with nucleicd)
  NucleicProtocolC/               # C-ABI DLL target (+ include/nucleic_protocol.h, exports.def)
  NucleicCore/Container/SandboxEngine.swift
  NucleicCore/Container/Windows/{WslcContainerEngine,WslcBrokerClient,WslcProcessHandle}.swift
  NucleicCore/Windows/{WindowsSupport,WindowsSignals,ProcessTree,FilePermissions,
                       WindowsEnvironment,WindowsFileIO,PowerBlocker,      # item 9
                       CredentialStore,WindowsDNSSD,NIOLANTransport,
                       NIOLANListener}.swift                              # later items
  NucleicCore/PortableLogging.swift          # lifted from LinuxSupport.swift
  NucleicCore/{NucleicPaths,SecretFile}.swift  # shared seams behind the Windows legs
Tests/NucleicProtocolCTests/
fixtures/protocol-abi/
.github/workflows/windows.yml

11. Windows hostd design (Sources/nucleic-hostd)

Factor the shared headless bring-up out of Sources/nucleicd/Nucleicd.swift (makeStore, blank-API-key scrub, startup choreography ~lines 142-171) into an internal NucleicHeadless helper used by both nucleicd and hostd. hostd-specific duties:

  • Single-instance mutex; rendezvous file write (owner-only DACL); SetConsoleCtrlHandler.
  • Start order: GRDB store → AppStore graph → broker spawn + session.ensurestartSyncServer() → local renderer listener (loopback TCP, port 0, Noise XXpsk0 with PSK auto-approval — the local analog of meshAutoApprovePairing, Nucleicd.swift:101) → LAN listener + DNS-SD advertise → relay/tailnet transports per settings.
  • PowerBlocker held while sessions active. Windows Job Object on brokerd so it dies with hostd.
  • Transports env default: NUCLEIC_SYNC_TRANSPORTS=lan,relay (+tailnet when configured) vs nucleicd's relay-only default.

12. Work breakdown

Progress: items 110 are implemented; item 10 includes the JSON bridge, exhaustive tag surface, all 115 golden CBOR↔JSON vectors, DLL entry points, NIO TCP channel, and shared projection splice adapter. See "Execution status" at the top of this document for per-item verification state, divergences, and known gaps. Items 1116 remain (item 14 has build-script/CI design groundwork, but the MSIX/release implementation is not done).

Ordered; items 14 are Darwin-first refactors verifiable by the existing macOS suite and safe to start immediately on macOS hardware.

  1. Manifest & gatingPackage.swift only: onWindows/nonDarwin, widened platform conditions, Windows exclude list, new targets.
  2. PortableLogging — lift the Logger shim from LinuxSupport.swift; WindowsSupport.swift stubs.
  3. SecretStore seam — protocol + Keychain backend + retarget ~14 files (§4.2); DPAPI backend can land later with the Windows build.
  4. SandboxEngine seam — protocol + ContainerEngine conformance + retype ContainerManager and backend references (§3.1). macOS tests must stay green.
  5. Brokerwindows/NucleicBroker full RPC surface (§3.3) + fake-IWslc tests. Done for the compat surface: RPC surface, tests (18/18), and WslcFacade.cs rewritten against the real API and compiling against the real NuGet (§13.2). The D13 internal-COM arm is unwritten but unblocked — its entry point is confirmed on hardware (§13.2), and writing it (enumeration, reattach, pty) is the next task in the container subsystem, after one --internal-call vtable check.
  6. Swift wslc clientWslcBrokerClient / WslcContainerEngine / WslcProcessHandle + tests against a scripted fake broker exe (mirror the fake-claude adapter-contract pattern).
  7. Control planeNIOByteConn, gateway plumbing through ensureRunning, control-bridge.js TCP branch (guest contract unchanged).
  8. hostdNucleicHeadless factor-out; Sources/nucleic-hostd/Hostd.swift (§11); NIO LAN transport + WindowsDNSSD.
  9. Windows shimsWindowsSignals, ProcessTree (+ Job Objects), FilePermissions (+ shared SecretFile), WindowsEnvironment (registry PATH, setenv), WindowsFileIO, PowerBlocker, LoginShellEnv/PATH legs, NucleicPaths. (FileWatcher turned out not to be needed — §4.3.)
  10. Protocol DLL — target + C header + JSON bridge + golden fixtures + Swift tests.
  11. Interop + renderer coreNucleicProtocol.Interop, HostConnection.cs, RendererStore.cs, HostdLauncher, onboarding.
  12. Screens — Home → SessionDetail/Transcript → Composer → Approvals → Projects/ Settings → Panels (Terminal, Diff, Build&Run, Todos) → RemoteAccess/host picker.
  13. Remote parity — DNS-SD advertise + QR (reuse the mintPairingCode() path), relay (portable already), tailnet DLL + NucleicTailnet Windows leg.
  14. Packaging/release — MSIX projects, appinstaller templates, cloud/nucleic-updates /win/<channel>/latest routes, release-windows.ps1, Trusted Signing.
  15. naros multi-arch — extend os/ + .github/workflows/naros.yml to push an amd64+arm64 manifest list to GHCR (D10).
  16. Phi Silica provider (§9.2), accessibility/theming parity, polish.

Packaging details (item 14): MSIX Identities BLAKESLEE.Nucleic[.Dev/.Canary/.Beta/.RC] mirroring the bundle-ID scheme (Package.swift:27-35); runFullTrust; payload = three exes + NucleicProtocolC.dll + Swift runtime DLLs + MinGit; per-arch MSIX with .appinstaller <MainPackage ProcessorArchitecture> selection; feeds nucleic-<channel>.appinstaller on R2 beside the Sparkle appcasts with in-app update check reading the same feed (App Installer has no delta updates); publisher CN must match the Trusted Signing cert.


13. Milestones & exit criteria

M0 — Toolchain proof (riskiest first). Swift 6.3-windows (pinned in windows/props/swift-version.txt, x64 + arm64 toolchains; the pin has a floor — the hosted runners' VS 2026 STL hard-errors STL1000 on a bundled clang older than 20, which rules out Swift 6.2.x) builds NucleicProtocol; NucleicProtocolTests + NucleicCarbonTests (the designated cross-platform suites, Package.swift:391-401) green on hosted windows-2025 (x64) and windows-11-arm runners, including GRDB/SQLite linkage. Exit: windows.yml green on both arches; corelibs/toolchain gap list filed as issues.

M1 — De-risking spikes (parallel, checked into windows/spikes/). (a1) wslc API — DONE (windows/spikes/WslcApiDump, §13.1). The surface is verified member-by-member against the shipped assembly on WSL 2.9.4, the compat/internal split is established (D13), session reattach is settled, and uid semantics are answered (no uid on ProcessSettingssetpriv wrapper). (a2) wslc happy path — RUN AND GREEN, 2026-07-29. See §13.3. The whole path works on hardware; it found three broker bugs (all fixed), a BusyBox setpriv gap, and the 9P number that puts D8 in question. (windows/spikes/WslcSpike): GHCR pull of naros-agent → container with an NTFS ContainerVolume → exec in the bind-mounted worktree → stdio round-trip → signal → teardown, plus the 9P measurement (git status on the mount vs. a copy on container-local ext4, same container and repo) and a live D13 Tier 1 recovery test that kills the broker mid-session. It drives nucleic-brokerd over its real NDJSON surface rather than calling wslc itself — its csproj is plain net9.0 with no wslc reference, so it cannot drift into a second implementation, and a passing run is evidence about the shipping code. This is the first execution of anything in the container subsystem. (b) Gateway-TCP reachability — TODO, and it is the control-plane spike that matters (§13.2 retracted the hvsocket inversion: IWSLCVirtualMachine is unreachable from a client, so there is no VMID to bind AF_HYPERV to). Measure whether a Bridged wslc container can reach the host at the vEthernet (WSL) address WslcFacade now returns, under the default Windows firewall, and confirm control-bridge.js completes an MCP round trip over it. ContainerNetworkingMode is None | Bridged, so the NAT/mirrored matrix this originally called for does not exist. hvsocket stays as upside contingent on sourcing a VMID outside wslc (HCS enumeration) — not on the M2 path. (c) DLL: SwiftPM-built NucleicProtocolC.dll, C# P/Invoke connect-pair-echo round-trip against a Swift test host; settle symbol-export strategy. Exit: (a1) ✓. Remaining — 9P perf numbers with a mitigation decision; hvsocket-in-container reachability confirmed or the fallback promoted back; wslc API gaps filed upstream while preview feedback still lands (microsoft/WSL#41024 is the existing report, unanswered).

M2 — Headless host runs an agent. NucleicCore compiles on Windows (work items 19); hostd boots, GRDB at %APPDATA%, broker supervises the wslc session; nucleic-smoke (already cross-platform) pairs over loopback and drives a full Claude session: prompt → MCP approval round-trip → git-interceptor event → worktree commit on NTFS. Exit: e2e session with approval + interceptor events asserted from the transcript; containers survive hostd kill/restart and reconcile; runs on the self-hosted x64 runner (D11).

M3 — Renderer alpha. DLL interop + golden fixtures; HostConnection/RendererStore; Home, Sessions, SessionDetail transcript, Composer, Approvals; hostd auto-launch + rendezvous. Exit: dogfood-usable real coding session end-to-end from the UI; transcript-parity harness (replay recorded HostMsg streams through np_projection_apply vs. the Swift projector, diff the JSON) passes on a recorded fixture session.

M4 — Remote + panels + packaging. DNS-SD advertise + QR pairing (iPhone pairs to the PC over LAN), relay listener, remote-cockpit host picker (D12), Terminal/Diff/Build&Run/Todos/Settings panels, toasts + taskbar attention, MSIX per-channel + .appinstaller on R2 (dev + canary), Trusted Signing, ARM64 package. Exit: iPhone full remote session against a Windows host over LAN + relay; canary installs and auto-updates via the App Installer feed on clean x64 + ARM64 VMs.

M5 — Parity beta. Tailnet DLL + Windows leg; Phi Silica provider + fallback; accessibility/theming parity (4 palettes, 5 sizes); quota/OAuth flows; idle/memory policy tuning; wslc GA migration (fall 2026) absorbed behind the broker; beta channel live; winget evaluation. Exit: parity checklist (every §7 screen row + every §9 gap row) green; 14-day dogfood with no P0s.

13.1 M1 (a) findings — the real Microsoft.WSL.Containers (2026-07-29)

Identity. Package Microsoft.WSL.Containers 2.9.3 — the only version on nuget.org, and it tracks WSL's own version scheme, not the 0.1.0-preview.N this document assumed. Managed assembly is lib/net8.0-windows10.0.19041.0/**wslcsdkcs.dll** (namespace Microsoft.WSL.Containers), so the package name is not the assembly name. It also ships a native wslcsdk.dll + wslcsdk.h, a .winmd for C++/WinRT, and MSBuild/CMake targets that build container images as a build step. Its own README states the preview/breaking-change warning that D3 and §15 already assume.

How to re-derive this without a Windows box, when the version bumps: fetch https://api.nuget.org/v3-flatcontainer/microsoft.wsl.containers/<version>/microsoft.wsl.containers.<version>.nupkg, unzip, and read lib/*/wslcsdkcs.dll with MetadataLoadContext (it needs WinRT.Runtime.dll from Microsoft.Windows.CsWinRT on the resolver path). That is exactly what WslcApiDump does on a real machine, and the two should agree.

Mechanical corrections (fixed behind IWslc, no design impact)

WslcFacade assumed Actually
WslcService.GetServiceVersion() GetVersion()ServiceVersion {Major,Minor,Revision}
GetMissingComponents()ComponentFlags IReadOnlyList<Component>; Component = VirtualMachinePlatform | WslPackage | SdkNeedsUpdate (a list, not flags)
InstallComponentsAsync(flags) InstallWithDependencies() / InstallWithDependenciesAsync()
Session.CreateOrOpen(settings) new Session(settings) + Start()see below
SessionSettings.MemoryMB MemorySizeInMB (uint?); CpuCount is uint?; also Timeout, EnableGpu, VhdRequirements, StoragePath
Session.SessionTerminationHandler property event Terminated carrying SessionTerminationReason = Unknown | Shutdown | Crashed
PullImageOptions.Credentials = RegistryCredentials RegistryAuth is a string; ctor is PullImageOptions(uri)
PullImageOptions.Progress event Progress rides ImageProgress {Id, Status, CurrentBytes, TotalBytes} + ImageProgressStatus (Pulling/Waiting/Downloading/Verifying/Extracting/Complete), surfaced through the async PullImageAsync overload
ImageInfo.Reference/.Digest/.Size .Name / .Sha256 / .Size (ulong) / .CreatedTimestamp
ContainerSettings.Hostname HostName; image is ImageName; also EnableAutoRemove, Privileged, EnableGpu, PortMappings, NamedVolumes, DomainName
ContainerVolume(host, guest, ro) ✓ correct — ContainerVolume(windowsPath, containerPath, readOnly)
Container.RunProcess(settings) CreateProcess(settings) then process.Start() — two steps, which is what makes it possible to attach handlers before the process runs. The facade's single call could drop the first output chunk.
DeleteContainerFlags DeleteContainerOption
Signal as an int a named enum: None, SIGHUP, SIGINT, SIGQUIT, SIGKILL, SIGTERM. The RPC carries POSIX ints, so the broker needs an int→enum map, and anything outside that set (SIGUSR1) is unavailable.
Process.WriteStdin/CloseStdin GetInputStream() / GetOutputStream() (WinRT streams)
Process.OutputReceived(stderr, data) two events — OutputReceived and ErrorReceived, each (byte[] data). The broker's ProcOutput(procId, stderr, chunk) sink is still right; the facade just subscribes twice.
Error enum (ImageNotFound, ContainerNotFound, ContainerNotRunning, SessionReserved, RegistryBlockedByPolicy, …) — a better source for data.kind than string matching

There are TWO COM surfaces, and the SDK wraps the smaller one

WSL is open source, which settles this properly. wslc.exe is not special and does not have private powers: it is a COM client, and both interfaces it can talk to are checked in.

WSLCCompat.idl wslc.idl
Purpose SDK-facing. wslcsdk.dll — and therefore the C# projection — wraps exactly this. Service-internal. What wslc.exe itself calls.
Stability "Changes in this file must maintain backwards compatibility" "ABI breaking changes in this file are OK, since both client & server always ship together. The WSLC SDK must not use this file"
Entry point WSLCCompatSessionManager, CLSID a9b7a1b9-0671-405c-95f1-e0612cb4ce8f IWSLCSessionManager, IID 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760
Container list IWSLCSession::ListContainers(options, …, portMappings, …)
Container stats IWSLCContainer::Stats([out] LPSTR* Output) (JSON)
Attach to existing IWSLCSession::OpenContainer(Id), IWSLCContainer::Attach(DetachKeys, StdIn, StdOut, StdErr)
Session attach CreateSession only OpenSessionByName(DisplayName), EnterSession(name, storagePath, …), ListSessions()
pty IWSLCProcess::ResizeTty(Rows, Columns); CreateRootNamespaceProcess(…, TtyRows, TtyColumns, …)
VM identity IWSLCVirtualMachine::GetId([out] GUID* VmId), AcceptConnection, ConfigureNetworking, MapVirtioNetPort
Events progress/warning/crash callbacks + IWSLCPluginNotifier: OnContainerStarted(InspectJson), OnContainerStopping, OnImageCreated, OnImageDeleted

So the earlier conclusions were both wrong, in opposite directions. The SDK is not hiding anything — WSLCCompat.idl genuinely lacks all of it, and the native wslcsdk.dll export table is exactly the 60 documented functions plus DllGetActivationFactory, with nothing undocumented. But shelling out to wslc.exe is not the only alternative: the internal interface is ordinary COM with published IIDs, and calling it directly is what the CLI does.

IWSLCVirtualMachine::GetId deserves its own line. It hands back the VM's GUID, which is precisely what an AF_HYPERV bind needs — so §5's "spike + fallback" hvsocket path, filed as upside we might never reach, looked directly reachable, and with it true vsock parity with the macOS control plane.

Retracted, 2026-07-29 — see §13.2. Reading the IDL rather than the summary of it: IWSLCVirtualMachine is produced only by IWSLCVirtualMachineFactory::CreateVirtualMachine, and that factory is an input the SYSTEM service passes into IWSLCSession::Initialize / IWSLCSessionFactory::CreateSession. Nothing a client can reach — not IWSLCSessionManager, not IWSLCSession — hands one back. So a client cannot call GetId, and the VM GUID does not come from here. §5's ordering therefore stands as originally written (gateway TCP primary), and the hvsocket path is back to needing a VMID from somewhere else entirely.

D13 — the split: compat-SDK where it reaches, internal COM where it doesn't

Decided. WslcFacade binds to both surfaces, and which one answers a call is invisible above IWslc:

Path Surface Why
session create/terminate, image pull with progress, container create/start/stop/delete, proc.exec + event stdio + signals compat SDK (Microsoft.WSL.Containers) The hot path, and the contractually-stable surface. Event-mode stdio is the whole reason to use the SDK at all.
container.list / container.state sweep on reattach internal COMIWSLCSession::ListContainers §2.3's reconcile has no compat-surface equivalent. Returns port mappings alongside, which the compat surface never exposes.
container.stats internal COMIWSLCContainer::Stats (JSON) Feeds ContainerResourceSample. A cgroup read via runCapturing is the escape hatch if this one call proves unstable.
session + container reattach internal COMIWSLCSessionManager::OpenSessionByName / EnterSession, IWSLCSession::OpenContainer, IWSLCContainer::Attach The mechanism §2.3's broker supervision assumes, confirmed on hardware (§13.1 live findings): a second same-named Session constructs — the constructor is lazy — but its Start() refuses with ERROR_ALREADY_EXISTS. The compat surface cannot re-adopt a running session, and has no container enumeration at all.
Terminal panel tty (§7) internal COMIWSLCProcess::ResizeTty, CreateRootNamespaceProcess(…, TtyRows, TtyColumns, …) The compat surface has no pty at all, so this is the only way item 12's Terminal panel is real rather than a pipe.
control-plane transport (§5) internal COMIWSLCVirtualMachine::GetId The VM GUID an AF_HYPERV bind needs. See below.

No CLI arm. Shelling out to wslc.exe was considered and rejected: it would mean a process spawn per call, scraped text instead of HRESULTs and JSON, no event notifications, and a second mechanism to keep working — for a stability gain that is real but partial, since a WSL update that breaks the internal ABI can equally change CLI output. One mechanism, understood and pinned, beats two.

What this costs, and how it is contained

wslc.idl says outright that "ABI breaking changes in this file are OK, since both client & server always ship together". We are not both, so this is a genuine risk and the plan owns it rather than hoping:

  • Nothing above IWslc moves. SandboxEngine, ContainerManager, the §3.3 RPC surface and every line of Swift are already written against the seam. A broken ABI is a broker-local repair.
  • Degrade, don't die. The broker probes the internal interface at startup and reports what bound in its capabilities hello (§2.3). If it fails, the compat SDK still runs sessions, containers and agents — the port loses reattach, stats and the Terminal panel, and hostd is told so, rather than the sandbox failing wholesale. That is a bad day, not a dead host.
  • Pin and detect. The WSL version is pinned per release alongside the NuGet, and windows/spikes/WslcApiDump grows an IDL-hash check against the pinned revision of wslc.idl + WSLCCompat.idl, so a drifted interface is caught by a spike run rather than by a user. This is the same posture §15 already takes on the preview NuGet, extended to cover the interface it does not ship.
  • Cheapest binding. Marshalling wslc.idl from C# is real work — size_is arrays, system_handle, unions — so a small C++/WinRT shim inside the broker is likely cheaper and safer than hand-written ComImport interop. Measured instead (§13.2): hand-written ComImport works. IWSLCSessionManager binds and calls correctly from C# with [PreserveSig] and IntPtr placeholders for unused parameters. The shim stays in reserve for the one case that would still justify it — a reattached handle that cannot be projected back onto the compat surface, so that every operation needs hand-marshalling rather than just the lookup.
§5 revisited: hvsocket does NOT become the primary (corrected 2026-07-29)

This section previously argued that IWSLCVirtualMachine::GetId supplies the VM GUID an AF_HYPERV listener needs, so §5's ordering should invert and hvsocket become primary. That is withdrawn: per the IDL, no client-reachable interface returns an IWSLCVirtualMachine at all (see the retraction above). The VMID has no known source, so the argument had no premise.

§5 therefore stands as written: gateway TCP is the primary, with the address coming from GetAdaptersAddresses over vEthernet (WSL) on the broker side, since no wslc API surfaces one. That is what WslcFacade.EnsureSessionAsync implements today (via NetworkInterface, the managed wrapper over the same call), polling because the vNIC appears as the VM boots and Start() returning does not mean it is up.

hvsocket returns to being upside rather than a plan. Reaching it needs a VMID from outside wslc — HcsEnumerateComputeSystems, or the WSL VM's registry/HCS identity — which is a separate spike with its own stability story, and is not a prerequisite for M2. M1 (b) has since measured the gateway path and it works (§13.3) — a container reached an interface-scoped host listener under the default firewall — so gateway TCP is the committed transport and hvsocket stays unbuilt.

Why the CLI is not the answer, though it could have been

The gaps are confirmed by three independent sources — the C# projection, the native wslcsdk.h (no WslcListContainers either), and the official API reference — so they are not an artefact of the projection. They are gaps in WSLCCompat.idl itself, and the wslc.exe CLI shipping in the same WSL build has all of them:

wslc container ps                 # enumeration — what the compat surface has no call for
wslc container stats              # per-container resource usage
wslc run --rm -it  --name web    # interactive tty, and a name to address it by later
wslc container stop web           # …addressed by name from a DIFFERENT process

That last line is worth keeping even though the CLI is rejected: a container created earlier is addressable from a process that never held its handle, which means container state is service-backed — the premise §2.3's broker supervision rests on, now evidenced rather than assumed.

The limitation is publicly raised and unanswered by Microsoft (microsoft/WSL#41024 — "The WSL Container API is missing many features that the wslc.exe command already has"), so compat-surface parity is not something to plan around. D13 is the answer instead.

The findings themselves

  1. No container enumeration in the SDK. Session exposes CreateContainer and nothing that lists containers; the native header has no WslcListContainers either. Resolved by the CLI arm above (wslc container ps). What still needs a live answer is the session half: whether new Session(settings) with an existing name attaches or throws (Error.SessionReserved hints at the latter), and whether WSLC_CONTAINER_START_FLAG_ATTACH — native-only, unexposed in C# — is the in-SDK reattach primitive.

  2. No per-container statistics. There is no GetStatistics()Container offers Id, State, InitProcess and Inspect() (a string). So container.statsContainerResourceSample has no source, and sampleResourceUsage must instead exec cat /sys/fs/cgroup/... inside the container and parse it, the way a Linux-native engine would. Recoverable, but it is a different implementation from the one §3.3 sketched.

  3. No pty. ProcessSettings has CommandLine, EnvironmentVariables, WorkingDirectory and OutputModeno Terminal, no resize, and no UserId/GroupId. Two consequences:

    • §7's Terminal panel loses proc.exec(tty:true) / proc.resize. Either the panel degrades to a pipe-mode shell (no line editing, no ANSI sizing), or the pty is created inside the container (run the shell under script/socat) and resize becomes an in-band message.
    • The uid question §3.2 flagged is settled the way it hoped: there is no uid setting, so exec wraps argv in setpriv/su agent -c. Interceptors and nash don't care about the numeric uid, so this costs nothing.

§5 control plane: the gateway does not come from wslc

There is no HostGatewayAddress, and no address property anywhere on Session or Container. ContainerNetworkingMode is None | Bridged — not the NAT/mirrored pair §5 assumed. What does exist is ContainerPortMapping(windowsPort, containerPort, protocol) with a WindowsAddress, i.e. inbound host→guest forwarding, which is the wrong direction for the control plane.

So §5's primary transport survives, but its address must be obtained from Windows networking APIs on the broker side (GetAdaptersAddresses over the vEthernet (WSL) adapter) rather than from the SDK — EnsureSessionAsync still returns a gateway string, and nothing above IWslc changes. That needs confirming on hardware in phase 2, along with whether a Bridged container can reach the host at that address under the default firewall. If it cannot, §5's AF_HYPERV fallback stops being upside and becomes the plan.

The published docs are stale against the shipped package

Microsoft Learn's own C# sample uses MemoryMB, CmdLine and DeleteContainerFlags; the assembly in 2.9.3 has MemorySizeInMB, CommandLine and DeleteContainerOption. The native header references a WslcCanRun that it does not declare. Read the metadata, not the docs — which is what windows/spikes/WslcApiDump is for, and why it stays checked in.

One thing the docs did answer that metadata could not: pull progress is session.PullImageAsync(options) returning an async operation whose .Progress handler receives (operation, ImageProgress). That closes the last mechanical unknown in PullImageAsync.

Live session findings (2026-07-29, WSL 2.9.4) — CONFIRMED ON HARDWARE

WslcApiDump --session on Windows 11 amd64, WSL 2.9.4. Every line below is an observed result, not an inference; M1 (a) is complete:

  • The session constructor works and Start() succeeds. The compat SDK boots a real WSL VM from new Session(new SessionSettings(name, storagePath)) + Start(), exactly as transcribed.
  • The Session CONSTRUCTOR IS LAZY. A second Session with the same name constructs happily — it only captures settings. Its success means nothing, which is exactly how the first reading of this probe got it wrong.
  • Start() is where identity is enforced, and it REFUSES. The second Start() fails with ERROR_ALREADY_EXISTS (0x800700B7), "Cannot create a file when that file already exists". So the compat surface cannot re-adopt a running session, and reattach does need IWSLCSessionManager::OpenSessionByName / EnterSession on the internal interface. D13's reattach row is confirmed on hardware, not merely inferred from an IDL.
  • Note the code: ERROR_ALREADY_EXISTS, not WSLC_E_SESSION_RESERVED (0x80040607). The reserved code exists in wslc.idl but evidently means something narrower — do not key on it.
  • A read on an unstarted Session throws E_ILLEGAL_METHOD_CALL (0x8000000E, "Session has not been started"), which is a useful state signal for the facade.
  • The service and the SDK version independently. GetVersion() reports 2.9.4 against a NuGet pinned at 2.9.3 — a newer service with an older package, which is the benign direction, but it confirms the two move separately and that §15's pinning has to track both.

What this does to D13: confirms it. The row that was in question — session reattach — is settled in D13's favour, and the other four never depended on this. The lesson for WslcFacade is a design one: the constructor is not a session; Start() is. Anything the broker does to detect or adopt an existing session must go through the internal interface, and the facade must not treat a constructed Session as evidence that one exists.

Still unknown (needs a live run — phase 2)

Whether a second new Session(...) with an existing name attaches or throws; whether a session (as opposed to a container) survives the creating process exiting; whether wslc container ps sees containers the SDK created, and in what output format (a --json-style flag would save parsing); whether wslc … -it supports resize; the gateway address and its reachability from a Bridged container under the default firewall; and 9P latency for git status and npm install on an NTFS ContainerVolume.

13.2 Writing the facade against the real API (2026-07-29) — and what it turned up

§13.1 established the surface from a Windows box. This pass used it: Wslc/WslcFacade.cs was rewritten and — the part that had never been possible before — compiled against the real package, in a Linux container, with no Windows hardware:

dotnet build windows/NucleicBroker/NucleicBroker.csproj -p:UseWslc=true -p:EnableWindowsTargeting=true

EnableWindowsTargeting=true is the whole trick: it lets a non-Windows SDK restore the Windows targeting packs. The preview NuGet is public, so it restores anywhere. This closes a real gap in the loop — the one file that touches the SDK was previously unbuildable except on the dev box, and the fake-backed tests never compile it. build.ps1 -Target broker now runs this build too.

The API surface was re-derived the same way §13.1 describes (read wslcsdkcs.dll with MetadataLoadContext), and it agrees with §13.1 member-for-member. Four things it adds:

  1. Container has no Name, and Session has no GetContainers(). §13.1 recorded "no container enumeration"; the sharper fact is that a container is reachable only through the handle CreateContainer returned. Container exposes Id, State, InitProcess and Inspect() — nothing to match a name against. So the facade keeps its own name→handle roster, and container.list answers from that roster rather than from the service. Two consequences, both now reported through capabilities instead of being discovered at a call site: the roster cannot see a container this process did not create, and it dies with the process. A restarted broker has an empty sandbox as far as ContainerManager.reconcile can tell. This is the sharp edge of the D13 reattach gap, and it is worse than "no enumeration" sounded.

  2. Session.Authenticate(uri, username, password) → string is where RegistryAuth comes from. §13.1 correctly recorded that PullImageOptions.RegistryAuth is a plain string rather than a credentials object, but not what produces the string. It is this call — so a private GHCR pull is Authenticate("https://ghcr.io", user, token) then assign. Without it the field has no producer and image.pull with auth cannot work at all.

  3. ImageInfo.Sha256 is an IBuffer of raw bytes, not a string, so the facade hex-encodes it into the sha256:<hex> digest the rest of Nucleic speaks. ImageInfo.Size is UInt64.

  4. The package's own lib folder lies about its targeting pack, and it is a hard build error. The assembly ships under lib/net8.0-windows10.0.19041.0/, and the csproj pinned net9.0-windows10.0.19041.0 deliberately, reasoning that matching the folder avoided a needless targeting-pack requirement. But wslcsdkcs.dll is itself compiled against Microsoft.Windows.SDK.NET 10.0.26100.79, so a 19041 pack cannot load it:

    error CS1705: Assembly 'wslcsdkcs' ... uses 'Microsoft.Windows.SDK.NET, Version=10.0.26100.79'
    which has a higher version than referenced assembly ... 'Version=10.0.19041.38'
    

    Bumping the TFM to 26100 is not sufficient — a TFM selects a pack by major revision, and the .NET 9 SDK's default for 26100 is 10.0.26100.38, still below the floor. It needs an explicit <WindowsSdkPackageVersion>, and the value is 10.0.26100.80, because .79 — the version the error names — was never published to nuget.org. Three separate traps in one two-line property group, and the reflection-only WslcApiDump spike hits none of them (it binds no SDK type at compile time), which is why this surfaced only when real code was written.

ANSWERED ON HARDWARE, 2026-07-29: the internal arm has an entry point

WslcApiDump --internal on Windows 11 amd64 (WSL 2.9.4):

Class IWSLCSessionManager
WSLCCompatSessionManager {a9b7a1b9-0671-405c-95f1-e0612cb4ce8f} YES
WSLCCompatSessionManagerFactory {9fcd2067-…} E_NOINTERFACE
LxssUserSession {a9b7a1b9-…-ce7e} E_NOINTERFACE
LxssUserSessionInBox {4f476546-…} REGDB_E_CLASSNOTREG (not registered on this box)

So the entry point is the compat coclass itself — the same class wslcsdkcs.dll activates for the stable SDK surface also implements the service-internal manager. One object, two faces, which is the ordinary COM pattern and exactly what wslc.exe must be doing. CoCreateInstance the compat CLSID, then QueryInterface for 82A7ABC8-6B50-43FC-AB96-15FBBE7E8760. Record that CLSID as the internal arm's entry point; it is not written down in wslc.idl, which is what made this a question at all.

Two subsidiary results from the same run:

  • IWSLCVirtualMachine is refused by every class, which confirms §13.1's retraction on hardware rather than by reading an IDL. The hvsocket-primary inversion was genuinely unfounded, and §5's gateway-TCP ordering is now evidenced rather than merely restored.
  • IWSLCSession is refused by the manager, which is expected and not a gap: a session is returned by OpenSessionByName / EnterSession / CreateSession, not QI'd off the manager. It confirms the manager is a manager rather than a do-everything object.

The vtable matches the IDL — confirmed by --internal-call on the same box. GetVersion() (slot 3) returned 2.9.4, agreeing with the compat WslcService.GetVersion() printed in the same run — so it is not merely a well-formed call, it is the same service answering through both faces. ListSessions() (slot 6) then returned S_OK. Slots 36 of IWSLCSessionManager are therefore the ones wslc.idl publishes.

This retires the C++/WinRT shim idea. §13.1 guessed that "a small C++/WinRT shim inside the broker is likely cheaper and safer than hand-written ComImport interop". For the manager that is now measured, and hand-written ComImport simply works — [PreserveSig], IntPtr placeholders for the parameters we don't use, and slot order taken from the IDL. Keep the shim in reserve for the one case that would still justify it (see the handoff question below), not as the default.

Enumeration is proven end-to-end. Re-run as --session --keep --internal-call (create a session, then re-adopt it through the internal interface — §2.3's reattach story in one process), ListSessions returned #6 "nucleic-spike" (creator pid 10720). The name came back correct, so the WSLCSessionListEntry layout — two DWORDs followed by two inline wchar_t buffers, not pointers — marshals as declared. WSLCContainerEntry (Name[256], Image[256], Id[65], two ULONGLONGs, a state enum) is the same shape behind the same size_is(, *Count) double pointer, so ListContainers should follow directly.

OpenSessionByName needs IMPERSONATE — brokerd will hit this

The same run failed OpenSessionByName("nucleic-spike") with 0x80070542 — on the session ListSessions had just listed by name. That is HRESULT_FROM_WIN32(1346) = ERROR_BAD_IMPERSONATION_LEVEL, and it is a COM security failure, not a missing object: the service impersonates the caller to resolve a per-user session, and a .NET COM client is handed RPC_C_IMP_LEVEL_IDENTIFY by default — enough for the server to check who we are, not to act as us. GetVersion and ListSessions never impersonate, which is precisely why those two succeeded and made this look like a per-method capability gap.

Two fixes, and the choice differs by process:

  • The spike now calls CoSetProxyBlanket(proxy, …, RPC_C_IMP_LEVEL_IMPERSONATE, …) on the manager right after the QI. Surgical, per-proxy, and works after marshalling has happened.
  • nucleic-brokerd should call CoInitializeSecurity with RPC_C_IMP_LEVEL_IMPERSONATE once at startup, before its first COM call — it covers every proxy the process will ever hold, including ones handed back by other calls. It must genuinely come first, or it fails RPC_E_TOO_LATE. Note the ordering hazard: the compat SDK makes COM calls of its own, so this has to precede any WslcService/Session use in Program.cs, not merely precede the internal arm.

This is worth flagging loudly because the failure is so misleading — a security error that reads as "not found", on a per-user object, only on the methods that matter for reattach. The probe used to print "expected if no session of that name is running" underneath it; it now decodes the code and refuses to blame absence.

The handoff question — what actually sizes the internal arm

IWSLCSession (internal, EF0661E4-…) and IWSLCCompatSession (SDK-facing, DD7B2EF9-…) are distinct interfaces. WSLCCompatSessionManager has already proved that one object can wear both faces, so the question is whether the session handed back by OpenSessionByName does too:

  • If it QIs to IWSLCCompatSession → pass the pointer to Session.FromAbi() and a re-adopted session is driven by the existing facade code. The internal arm shrinks to find, then hand off: OpenSessionByName + ListContainers + OpenContainer, and nothing else in WslcFacade changes. Same trick should then apply to containers via IWSLCCompatContainer (8C3C91FA-D550-41B9-AD9D-23DCBF96F549) and Container.FromAbi().
  • If it does not → every re-adopted handle is internal-only, and start/stop/delete/exec each need a second hand-marshalled path. That is a far bigger arm, and the case where the C++/WinRT shim earns its keep after all.

Partly answered, and the encouraging half was misleading. With the proxy blanket raised, OpenSessionByName("nucleic-spike") opened the running session and the returned IWSLCSession QI'd successfully to IWSLCCompatSession. But the step that actually matters — Session.FromAbi(ptr) — threw InvalidCastException, so the handoff does not work as written, and the "small shape" below is not yet available.

The likely cause is that there are three layers, not the two this document has assumed throughout:

Layer Where it lives Interface
service-internal COM the WSL service IWSLCSessionManager, IWSLCSession (wslc.idl)
compat COM the WSL service IWSLCCompatSession (WSLCCompat.idl) — our QI hit this
WinRT wslcsdk.dll, client-side what Microsoft.WSL.Containers.Session projects

wslcsdk.dll exports DllGetActivationFactory, so its Session is an in-process wrapper that holds compat COM proxies rather than being one — and FromAbi QIs for the WinRT default interface, which a service-side pointer will never implement. The C# projection is a projection of layer 3, not of layer 2, and nothing in the SDK converts a compat pointer into a WinRT wrapper (Session's only constructor takes SessionSettings).

The probe now settles this decisively without needing the WinRT IID: it compares COM identity (the canonical IUnknown pointer) of the SDK-created Session against the one OpenSessionByName returned, and separately asks whether the SDK's own object implements IWSLCCompatSession. Different objects confirms the client-side-wrapper reading; the same object means FromAbi failed for some other reason and the hypothesis is wrong.

Sequence the arm in two tiers so M2 does not wait on this. Tier 1 needs nothing that is not already proven on hardware; only Tier 2 depends on the question above.

Tier What it does Status
1 — recover On broker restart: OpenSessionByName the orphaned session, ListContainers for reporting, then IWSLCSession::Terminate and create a fresh session through the compat SDK. Turns "broker restart ⇒ dead sandbox needing a manual wsl --shutdown" into automatic clean recovery. Containers do not survive — but they do not survive today either, and ContainerManager.reconcile already handles an empty sandbox. DONE and CONFIRMED ON HARDWARE (§13.3): killed a broker mid-session, the next one enumerated 5 containers, terminated the orphan and restarted clean.
2 — adopt Keep containers running across a broker restart and re-attach to their stdio. Blocked on the handoff above. If the identity test says "different objects", this needs exec/stdio driven entirely through internal COM (IWSLCContainer::Exec, IWSLCProcess::GetStdHandle) — handle-based rather than event-based, and the point at which the C++/WinRT shim earns its keep after all.

Tier 1 as built (Wslc/WslcInternal.cs), and the four things in it that are not obvious:

  1. Entry point is the compat coclass. CoCreateInstance(WSLCCompatSessionManager, a9b7a1b9-0671-405c-95f1-e0612cb4ce8f) asking directly for IID_IWSLCSessionManagerwslc.idl has no activatable class of its own.
  2. CoInitializeSecurity(RPC_C_IMP_LEVEL_IMPERSONATE) runs before the first COM call, from WslcFacade.Create() rather than Program.cs, so the ordering constraint sits next to the code that depends on it. Program.cs calls Create(), never new WslcFacade() — the latter compiles fine and silently costs recovery. A per-proxy CoSetProxyBlanket backs it up, since the process-wide call is order-dependent and the per-proxy one is not.
  3. ListContainers is method #19 behind 18 placeholders. Vtable slots are fixed by declaration order, so the four predecessors taking a by-value WSLCHandle union are declared as IntPtr and never marshalled. The interface carries a "do not reorder, do not delete an unused entry" warning, because either silently shifts every slot below it. Options are passed with Flags = All, or stopped containers vanish from the recovery report.
  4. Every failure degrades to "no recovery." TryBind returning null is normal on a machine without WSL (REGDB_E_CLASSNOTREG is reported as the onboarding state, not a fault), and the facade then throws session_exists naming wsl --shutdown exactly as before. recover joins the hello capabilities only when the arm actually bound.

Restart is retried after Terminate() rather than reported as a failure: the service tears the VM down asynchronously, so the next Start() can still see the old name for a moment.

Had the handoff worked, the arm would have taken this shape — kept here because Tier 2 revisits it:

CoInitializeSecurity(RPC_C_IMP_LEVEL_IMPERSONATE)          // brokerd startup, before ANY COM call
CoCreateInstance(WSLCCompatSessionManager) → QI IWSLCSessionManager
  OpenSessionByName(name)     → IWSLCSession → QI IWSLCCompatSession → Session.FromAbi()
  IWSLCSession::ListContainers                                      → the roster
  IWSLCSession::OpenContainer(id) → IWSLCContainer → QI IWSLCCompatContainer → Container.FromAbi()

Internal COM would be used only to find things, with everything then driven by the existing compat code and nothing in WslcFacade's operation paths changing. FromAbi is the load-bearing step, and it is the one that failed.

Two implementation notes that survive regardless of tier, both cheap to get wrong:

  • Vtable slots are fixed by declaration ORDER, not by signature. ListContainers is IWSLCSession's 19th method, and the 18 ahead of it include four taking WSLCHandle — a tagged union — by value. None of that matters, because a method never called is never marshalled: declare the predecessors as IntPtr placeholders and give precise signatures only to OpenContainer and ListContainers. The probe already relies on this (it called ListSessions at slot 6 with placeholders ahead of it).
  • A successful QI is not a usable object. IWSLCCompatSession QI'd fine and FromAbi still threw — the lesson being that these three layers share an object graph in ways that QI alone does not reveal. Any future "we can just project it" step needs the same end-to-end check (construct it, then call something on it) rather than stopping at the cast.

The container-level handoff (IWSLCContainerIWSLCCompatContainer, 8C3C91FA-D550-41B9-AD9D-23DCBF96F549) was going to be the same trick one level down, and is now expected to fail the same way. It is only worth re-testing if the identity probe overturns the client-side-wrapper reading.

dotnet run --project windows\spikes\WslcApiDump -- --session --keep --internal-call

Tear down afterwards with wsl --shutdown.

Why the arm was blocked before that run

D13 routes five capabilities to the service-internal wslc.idl, and reading that IDL directly (it and WSLCCompat.idl are in the open-source WSL repo, and both were fetched and read for this pass) turned up two obstacles it had assumed away:

  • There is no CLSID for IWSLCSessionManager. wslc.idl declares interfaces and no activatable class, so CoCreateInstance had nothing to name. The only registered coclasses anywhere in the set belong to the compat surface and to the WSL service proper. Resolved by the run above: the compat coclass answers the QI.
  • IWSLCVirtualMachine is not reachable from a client — only IWSLCVirtualMachineFactory::CreateVirtualMachine produces one, and the SYSTEM service owns the factory. Confirmed by the run above, so the control-plane row of D13's table has no route in the shipped IDL — it would take Microsoft exposing the VM object, not work on our side.

The other four rows (enumeration, stats, reattach, pty) were always correct in what they need and sound in where it lives. Note that stats has since been served another way — the facade's cgroup read — so the internal arm's remaining value is enumeration, reattach and the Terminal panel.

What to do next, in order. The probe is written and checked in (windows/spikes/WslcApiDump/InternalComProbe.cs):

dotnet run --project windows\spikes\WslcApiDump -- --internal        # QI only — cannot crash
dotnet run --project windows\spikes\WslcApiDump -- --internal-call   # also calls through

The first run of this probe (2026-07-29) is VOID — do not treat its output as evidence. It reported "IWSLCSessionManager is NOT reachable from any known coclass", and that conclusion was a bug in the probe, not a fact about WSL. It activated with typeof(object).GUID believing that to be IID_IUnknown; it is not (it is the CLR's type GUID for System.Object, ff77e388-6558-35eb-89cb-5ef50f4b9be2), so every class factory was asked for an interface that does not exist and correctly answered E_NOINTERFACE at activation. That reads exactly like a real capability answer, which is what made it dangerous. Fixed, plus the probe now shouts if it ever sees E_NOINTERFACE from an IUnknown activation — an impossible combination, since every COM object implements IUnknown — and falls back to activating each internal IID directly, because a class factory may legitimately refuse IUnknown-first activation. The one salvageable line from that run: LxssUserSessionInBox answered REGDB_E_CLASSNOTREG, which precedes any IID check, so that class really is unregistered on the dev box.

--internal activates each of the four registered coclasses in WSL's IDLs (WSLCCompatSessionManager, its factory, LxssUserSession, LxssUserSessionInBox) and QIs each for IWSLCSessionManager, IWSLCSession and IWSLCVirtualMachine. It never calls a method, so a vtable mismatch cannot take it down, and QI alone answers the entry-point question. --internal-call then calls GetVersion (slot 3) and ListSessions to confirm the vtable really is the IDL's — that one can crash, which is itself the finding, and is why it is opt-in.

Both have now been run — see the answer above. --internal said yes (via WSLCCompatSessionManager); --internal-call is the remaining step, and it must precede any interop, because a QI proves the IID is recognised and says nothing about the method layout.

13.3 First execution of the container subsystem (2026-07-29)

windows/spikes/WslcSpike driving nucleic-brokerd against the live wslc service on Windows 11 amd64 / WSL 2.9.4. Everything in §3 had compiled and none of it had ever run; this is the first evidence any of it works. Run over ssh from the dev Mac to the Windows box, with the broker built from a staged copy (the files were not yet on origin/dev).

Confirmed working, in order:

Step Result
hello brokerd 1.0.0, wslc 2.9.4; capabilities components, session, image, container, proc, stats, recover
D13 Tier 1 bound recover is present — WSLCCompatSessionManager → QI IWSLCSessionManager works from the broker, not just the spike, and CoInitializeSecurity ran early enough
components.missing empty
session.ensure started a session and resolved the §5 gateway to 172.30.16.1
image.pull docker.io/library/alpine:latest in 4.5s, with progress phases Pulling → Downloading → Verifying → Extracting → Complete arriving as notifications
container.create succeeded with an NTFS ContainerVolume and NetworkingMode = Bridged

§5's gateway question is answered: 172.30.16.1. No wslc API surfaces this — the facade derives it from the WSL vEthernet adapter — and the whole control plane depends on it. It is a real, private, non-loopback address, which is what the gateway-TCP transport needs. What remains for M1 (b) was only whether a Bridged container could reach the host there under the default firewall — and it can (below).

The error-translation design is validated by an unhappy path. A failed proc.exec came back as kind=not_running even though the COM message was the useless "The text associated with this error code could not be found." — because Translate keys on the HRESULT number and never on the message text. That was a deliberate choice in §13.2; this is the run that shows it mattering.

One bug, in the spike rather than the port: the run stopped at the first exec with not_running, because alpine's PID 1 is /bin/sh, which exits immediately without a tty — so the container was Exited before the exec, and container.state had already said stopped. narOS does not have this problem (naros-init is PID 1 and stays up, docs/NAROS.md). Fixed two ways: a --sleep-init flag that supplies a long-running init, and a state check immediately after container.start that fails with that explanation — because "PID 1 exited" and "the container failed to start" look identical and have nothing in common as remedies.

The rest of the path now works too, after the three bugs below were fixed. Confirmed on hardware: exec with stdout and stderr captured and the HostName setting honoured (hello from nucleic-spike); the NTFS bind mount readable at /work with the repo's real files and .git present; container.stats returning live cgroup counters (cpuUsageUsec: 32322679, memoryUsedBytes: 34697216, memoryLimitBytes: -1, oomKills: 0 — note -1 is the "unlimited" sentinel working as designed); container.stop + container.delete.

D13 Tier 1 recovery is CONFIRMED END TO END. The spike killed the broker with a session up, started a fresh one, and it recovered:

wslc: recovered orphaned session 'nucleic-spike' — 5 container(s)
      [nucleic-spike-c6, c5, c4, c2, c1], terminated=True
wslc: session 'nucleic-spike' restarted after recovery
RECOVERED — fresh session up, gateway 172.30.16.1

Three things that proves at once: IWSLCSessionManager::OpenSessionByName works from the broker; IWSLCSession::ListContainers works and returned real container names, so the hand-written WSLCContainerEntry marshalling — inline char[256] buffers behind a size_is(, *Count) double pointer — is correct; and Terminate + restart-with-retry clears the orphan. The whole hand-written ComImport approach is validated on live data rather than by a QI alone.

It also incidentally cleaned up the five stale containers earlier runs had leaked, which is the same "roster is process-local" gap in a different costume.

Three bugs the run found, all now fixed

  1. proc.exec responded after starting the process. A command that finishes instantly (echo) put proc.stdout/proc.exit on the ordered outbound queue ahead of the response carrying the procId that names them, so a client waiting for that exit waited forever. Fixed by enqueueing the response before IWslcProcess.StartAsync — the same reason wslc itself splits CreateProcess from Start, applied at the RPC boundary. The mirror-image hazard was then confirmed and fixed in WslcBrokerClient.swift (see item 6): the Swift client had the same hang for the same reason, because completing the response's continuation only schedules it while the notification drain is a separate task. It now buffers events for procIds it has not yet learned. The C# spike, being single-flight, takes the simpler route of accepting any procId.
  2. That fix introduced a double response. When StartAsync then failed, the generic handler emitted a JSON-RPC error under an id that had already been answered — two responses for one id. Now a post-response start failure is reported the way the process itself would: the reason on proc.stderr, then proc.exit with 126 (the shell's "found but not executable").
  3. ERROR_ALREADY_EXISTS was mapped to session_exists unconditionally. wslc returns that code for a container name conflict too, so a stale container reported itself as a stuck session — wrong diagnosis, wrong remedy (wsl --shutdown instead of removing one container). Split out as WslcError.AlreadyExists; the session paths catch the code by number before reaching Translate, so anything arriving there is the container kind.

setpriv does not exist on BusyBox, and the facade now refuses rather than escalating

The uid-drop wrapper assumed util-linux. BusyBox ships a setpriv that supports only capability flags — no --reuid/--regid at all — so on an alpine-based image the wrapper failed with unrecognized option: reuid=501. narOS is unaffected (naros-tier-agent depends on util-linux-extra), but a user-supplied image would have been.

The facade now probes once per container (setpriv --reuid=0 --regid=0 --init-groups -- true, cached on the roster entry) and refuses the exec with kind=unsupported when it cannot drop privileges. Probing for the binary is not enough — BusyBox has one, it just cannot do this. The refusal is deliberate: the alternative is running the agent as root in the one place the sandbox's user separation is enforced, which is a silent privilege escalation, not a degradation.

The real naros-agent image works, and needs no auth

ghcr.io/abkslm/naros-agent:26.07 pulls publicly (76 s, no credentials) and resolves on amd64, so the --registry-token plumbing is not needed and Session.Authenticate stays untested. On that image:

  • naros-init runs as PID 1 and the container stays up. The image carries no default CMD — wslc answers no command specified without one — which is exactly why both engines name init explicitly (WslcContainerEngine passes initArgv: ["/usr/sbin/naros-init"] and sets NAROS_BRIDGE=1).
  • The setpriv uid drop WORKS: id -u; id -g inside the container returned 501/501. The §3.2 fallback for wslc's missing ProcessSettings.UserId is confirmed on the supported image.
  • The control plane is reachable from naros using node as the client — which is the real one, since control-bridge.js is node. The host saw PING and the guest read the response back.

One divergence from the macOS engine, not yet closed: ContainerEngine probes for /usr/sbin/naros-init and falls back to sleep infinity plus a supervised node control-bridge.js for non-narOS images (legacy v7, custom). WslcContainerEngine hardcodes the naros path, so a custom image without it fails to start. Deferred, not excluded (§9.4a): it needs the same /bin/sh -c "if [ -x … ]" shape, and nothing before custom-image support depends on it.

Still not exercised: proc.signal, proc.stdin, and anything tty (unsupported for now).

M1 (b) ANSWERED: the guest reaches the host at the gateway — M2 is unblocked

==> §5 control plane: can the guest reach the host at the gateway?
    host listening on 172.30.16.1:52668 (interface-scoped, not 0.0.0.0)
    guest: printf 'GET / HTTP/1.0\r\n\r\n' | nc -w 5 172.30.16.1 52668
    REACHABLE — the guest completed a TCP round trip to the host.
    host saw: GET / HTTP/1.0

The listener was bound to the WSL-facing address only, never 0.0.0.0 — the posture §5 requires — and a container opened a TCP connection to it and got a response back, under the machine's default firewall policy with no rule added. That is the whole premise of §5's primary transport, and every agent session rides it: MCP approvals, the git/gh interceptor endpoints, nash's shell reports.

Consequences:

  • §5's gateway-TCP transport is validated end to end, so the ordering restored in §13.1's retraction is not merely the fallback — it is a working design.
  • The AF_HYPERV path is not needed. Which is fortunate, because §13.2 found no client route to the VM GUID it would require. Had this test failed, the port would have needed a VMID from outside wslc entirely (HCS enumeration) before an agent could run at all.
  • §5 step 5's firewall remediation stays as onboarding insurance, not a prerequisite. One machine passing is not every machine passing — enterprise policy is exactly the variance §5 flags — so the connectivity self-test and its New-NetFirewallRule remedy still earn their place, now with a known-good baseline to compare against.

What is still untested above the transport: an actual HTTP round trip through control-bridge.js to MCPApprovalServer with a bearer token, which is M2's own gate rather than M1's.

9P costs ~20x, not ~170x — D8 survives, with a caveat on writes

The first pass on this overstated the problem by an order of magnitude, and the corrected numbers are below. It measured find . -type f on an alpine container, giving 164x and then 177x on a re-run, and reported that as the 9P cost. Two things were wrong with it: find walks everything (titan's checkout includes .build, so 29,836 files against the 1,945 git status actually stats) and it has no index, so it pays a per-file lstat that git avoids. The stated caveat — "the ratio is the robust finding" — was itself wrong: the ratio was the inflated part.

Re-measured on the real naros-agent image with real commands, median of 5:

Operation /work (NTFS via ContainerVolume) /tmp/ext4 (container-local) ratio
git status --porcelain 1,510 ms (1,4711,563) 65 ms 23x
write 2,000 small files (the npm install shape) 3,864 ms 253 ms 15x

So the honest figure is ~1523x, and ~1.5 s per git status — a visible pause on every operation rather than the 40-second stall the proxy implied. Writes cost ~1.9 ms per file across the mount, which extrapolates to roughly a minute for a 30,000-file npm install against ~4 s locally. Copying the tree in (cp -a /work /tmp/ext4) took 153 s, consistent with that.

D8 stands. §15 says surface this rather than silently relocating repos, so it is recorded, not acted on — but the reading has changed from "possibly untenable" to "workable with the mitigation §15 already anticipated":

  • node_modules, build output and other write-hot directories belong on a ContainerNamedVolume. That is where the 15x write cost concentrates and where it hurts most; moving them off the mount is the single highest-value change and does not disturb D8.
  • The working tree stays on NTFS. git status at 1.5 s is a pause, not a blocker, and keeping it host-visible is what lets GitRunner/WorktreeManager work unchanged — which is D8's whole point. .git cannot move for the same reason.
  • Moving the clone into the session's ext4 (host access over \\wsl$) stays available if dogfooding shows 1.5 s is intolerable, but it inverts D8's premise and is no longer indicated by the numbers.
  • Still unmeasured: a real npm install, and a repo substantially larger than this one. The write benchmark is a proxy, if a much closer one than find was.

The lesson worth keeping: a proxy metric chosen for portability (find, so it ran on any image) produced a number that would have driven a wrong decision about a locked one. The re-measure on the actual image was the right call.

No wslc knob exists to soften this: ContainerVolume carries only (windowsPath, containerPath, readOnly) — no cache mode, no metadata option.


14. Verification strategy

14.0 There is no Windows GitHub CI — the gate is windows/build.ps1 (decision, 2026-07-29)

.github/workflows/windows.yml was authored (item 14), never enabled, and has now been deleted. Do not recreate it without asking. The reasons it is off:

  • Hosted Windows minutes bill at 2x and the workflow fanned out to five jobs — §14.1 called this "the leg that empties the budget" before a single run had happened.
  • The self-hosted alternative (D11) is a machine to own, patch and trust; the same box is far more useful running build.ps1 interactively, where a failure is inspected in seconds rather than in a log tail.
  • The Windows legs are not, today, guarding against unattended drift from many contributors. They guard one port under active development, on one machine, by whoever is doing the porting.

The gate is therefore windows/build.ps1 on the dev box, run before landing anything that touches the Windows legs:

./windows/build.ps1 -Target core -Test      # every declared target + NucleicCarbonTests
./windows/build.ps1 -Target protocol -Test  # the wire layer under the protocol-only manifest
./windows/build.ps1 -Target broker          # the C# contract tests

It exits non-zero on any failing leg — nothing is excused. That matters more than it sounds: the script used to report a failing core -Test as "FAIL (expected — items 10+ gap list)" and exit 0, and a real git-spawn regression read as a known gap for a day because of it.

SwiftPM 6.3.3 has two clean-test index-store failures on Windows: disabling the store still schedules test-discovery units and later fails opening files it never emitted; enabling it with parallel frontend jobs races on shared SDK/module unit files and reports permission denied. build.ps1 -Test therefore keeps indexing enabled and uses -j 1; ordinary build legs remain parallel. This is slower on a cold checkout, but it makes the documented gate deterministic.

The macOS suite, which does run unattended, remains the regression gate for everything cross-platform; the Darwin-first refactors (SecretStore, SandboxEngine, SecretFile, NucleicPaths, resolvedSpawnTarget) are deliberately shaped so most Windows work is verifiable there first. §14.1 below is kept as the design record for whenever a runner becomes worth it.

14.1-and-below: the original CI plan (retained, not in force)

  • Every CI run (from M0): NucleicProtocolTests + NucleicCarbonTests on Windows x64 + ARM64; the full macOS suite must stay green through the Darwin-first SandboxEngine/SecretStore refactors; DLL golden fixtures from Swift and C#.
  • Broker: C# unit tests against a fake IWslc; Swift WslcBrokerClient tests against a scripted fake broker exe (mirrors the fake-claude adapter-contract pattern).
  • M2 gate (self-hosted runner): nucleic-smoke e2e — full Claude session with approval + git-interceptor assertions from the transcript; hostd kill → restart → reconcile test; container idle-teardown test.
  • M3 gate: transcript-parity harness (recorded HostMsg replay, Swift vs. DLL projection JSON diff); renderer store unit tests over fixture event streams.
  • M4/M5 manual matrix: iPhone QR pairing over LAN / relay-only / tailnet; MSIX install → update → rollback across channels on clean x64 + ARM64 VMs with default firewall; accessibility spot-checks (high-contrast, palettes, text sizes); a longpath + LF-sensitive fixture repo through a full session + Autoship.

14.1 On-prem Windows runner (D11)

Hosted Windows minutes bill at 2x and this workflow fans out to five Windows jobs, so this is the leg that empties the budget; self-hosted runners are never billed. Every runs-on in windows.yml reads a repository variable that falls back to the hosted label, so switching is a settings change rather than a PR — and reverting is instant.

1. Prepare the machine (Windows 11 or Server 2025, x64; the Swift toolchain plus the BoringSSL/GRDB C++ builds want ≥4 cores, 16 GB RAM, ~60 GB free):

  • Visual Studio Build Tools with Desktop development with C++ and the Windows 11 SDK — this supplies the MSVC STL and SDK headers Swift's clang compiles against. The MSVC↔clang floor from §13 applies here too: VS 2022 (14.4x) and VS 2026 (14.5x) both work with the pinned Swift 6.3.x (clang 21); only Swift 6.2.x-and-older trip STL1000.
  • Git for Windows, then git config --system core.longpaths true — SwiftPM checkouts nest past MAX_PATH. (The workflow sets --global for the service account; --system as well saves surprises when you reproduce a failure by hand.)
  • .NET 9 SDK for the broker leg — otherwise actions/setup-dotnet re-downloads it every run, which works but is pure wall-clock.
  • Nothing else. The Swift toolchain is installed per run by gha-setup-swift from the windows/props/swift-version.txt pin, and vcpkg is cloned into RUNNER_TOOL_CACHE on first use (the hosted images preinstall it; a bare box does not).

2. Register the runner. Settings → Actions → Runners → New self-hosted runner (Windows x64), then run the generated config.cmd — its token is single-use. Give it a unique label and install it as a service so it survives reboot:

./config.cmd --url https://github.com/abkslm/nucleic --token <TOKEN> \
             --labels nucleic-win-x64 --runasservice

3. Point the workflow at it. Settings → Secrets and variables → Actions → Variables:

Variable Value Effect
WINDOWS_X64_RUNNER nucleic-win-x64 x64 protocol + core + broker legs go on-prem
WINDOWS_ARM64_RUNNER (leave unset) ARM64 legs stay on hosted windows-11-arm

Use the unique label, NOT the generic self-hosted. Deleting a variable sends that leg straight back to the hosted runner — the escape hatch when the box is down.

Caveats.

  • One box serialises. The five Windows jobs queue behind a single runner. Register two or three runner instances from separate directories on the same machine if that hurts.
  • A self-hosted runner is not sandboxed. Workflow code runs as the service account with the machine's credentials, in a workspace that persists between jobs. That is acceptable only because this repo is private, so no fork PR can execute on it. Never point a public repo at this machine.
  • ARM64 still bills. With one x64 box the two ARM64 legs stay hosted. If minutes are still tight, gate those to push + workflow_dispatch instead of every PR.
  • No incremental builds by default. actions/checkout runs git clean -ffdx, wiping .build/ each run. Setting clean: false on the on-prem legs buys a large speedup at the cost of stale-artifact risk — worth it only once the leg is otherwise stable.

14.1.1 Temporary dependency pins (revert when upstream lands)

Two Windows blockers live in SwiftNIO rather than in this repo. Both are pinned in Package.swift; check them off as upstream catches up.

Pin Why Revert when
swift-nio floor raised to 2.101.3 2.101.2's NIOPosix doesn't compile on Windows: HANDLE's Sendable conformance is unavailable, and CInt(IPPROTO_UDP) fails now that WinSDK imports IPPROTO as an enum (apple/swift-nio#3433). Never — this is a released version; the floor just records the requirement.
swift-nio-sslJoannis/swift-nio-ssl @ bc3bd900 NIOSSL's Swift layer has no Windows support upstream, not even on main as of 2.37.2 — only the C layer was fixed (#585). PR apple/swift-nio-ssl#567 ports it. apple/swift-nio-ssl#567 merges → restore .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0") and re-resolve.

While the NIOSSL fork pin is in place, swift build prints four "Conflicting identity for swift-nio-ssl" warnings — third_party/containerization, async-http-client, swift-nio-extras and grpc-swift-nio-transport all reach the upstream URL. The root package's URL wins and the build is correct, but SwiftPM warns this "will be escalated to an error in future versions", so this pin is not a resting place.

The BoringSSL duplicate symbol, and /FORCE:MULTIPLE. Every Windows link pulls in two vendored BoringSSLs — swift-crypto's CCryptoBoringSSL (the Noise crypto, since Windows has no CryptoKit) and swift-nio-ssl's CNIOBoringSSL (the relay WebSocket's TLS) — and they collide on one symbol:

lld-link: error: duplicate symbol: p_thread_callback_boringssl
>>> defined at swift-crypto/.../crypto/thread_win.cc:157
>>> defined at swift-nio-ssl/.../crypto/thread_win.cc:159

Both copies prefix their symbols (CCryptoBoringSSL_* / CNIOBoringSSL_*), which is why the identical pair links clean into nucleicd on Linux. The tell is which file collides: thread_win.cc compiles only on Windows, and BoringSSL's prefix maps (boringssl_prefix_symbols.h) are generated by building on a POSIX host — so symbols defined in Windows-only translation units are never in the map, and stay unprefixed in both copies.

Until one copy carries the prefix, every Windows link passes -Xlinker /FORCE:MULTIPLE: windows/build.ps1's Invoke-Swift, both swift test steps in windows.yml, and any bare swift build --product … you run by hand. Two things to know about that flag: it is global, so it downgrades any future duplicate to a warning (the unprefixed-Windows-symbol theory predicts more as the graph grows — read the warnings); and it resolves this collision by dropping one copy's .CRT$XLC TLS callback, so whichever library loses never registers its per-thread destructor and leaks that copy's thread-local BoringSSL state (error queue, RNG buffers) per thread. Each destructor is file-local, so neither can run against the other's slots.

The real fix is to prefix the symbol in one copy — plausibly a commit on the swift-nio-ssl fork already pinned above, since that pin has to be revisited anyway. Note it is more than a #define: thread_win.cc also names the symbol inside #pragma comment(linker, "/include:p_thread_callback_boringssl"), and a string literal does not macro-expand, so a rename that misses the pragma turns the duplicate into an undefined symbol. Worth filing upstream against both packages — anything linking swift-crypto and swift-nio-ssl together on Windows hits this.

14.2 SQLite on Windows, and building locally

Windows has no system SQLite. GRDB's GRDBSQLite is a .systemLibrary whose only declared provider is apt(["libsqlite3-dev"]), and a systemLibrary resolves through C header and library search paths, not the SwiftPM module graph — so no change to Package.swift can satisfy it, and NucleicCore cannot build until SQLite exists on disk and is discoverable.

windows/build.ps1 does that: it installs sqlite3 once via vcpkg and runs the requested leg. It finds an existing vcpkg via -VcpkgRoot, %VCPKG_INSTALLATION_ROOT%, the %LOCALAPPDATA%\vcpkg\vcpkg.path.txt breadcrumb, %LOCALAPPDATA%\Programs\vcpkg, %USERPROFILE%\vcpkg or C:\vcpkg, and clones one into %LOCALAPPDATA%\Programs\vcpkg if the machine has none. Note that %LOCALAPPDATA%\vcpkg itself is vcpkg's per-user data directory (registries, downloads, that breadcrumb), not an install root — it is only ever read.

./windows/build.ps1 -Target protocol -Test    # the required leg
./windows/build.ps1 -Target core -Test         # full manifest + NucleicCarbonTests
./windows/build.ps1 -Target broker            # C# contract tests, no Swift
./windows/build.ps1 -Target core -Persist     # + make plain `swift build` work forever

Two mechanisms, and the choice between them is not stylistic:

  • CPATH for headers. clang appends it to the header search path and still auto-detects the MSVC and Windows SDK headers. -Persist writes it to your user environment, after which a bare swift build --target NucleicCore resolves sqlite3.h with no flags, from any shell or editor. Do not use %INCLUDE% for this: clang stops auto-detecting the whole platform header set as soon as %INCLUDE% names one existing directory (clang/lib/Driver/ToolChains/MSVC.cpp, if (Found) return;).

  • -Xlinker /LIBPATH: for sqlite3.lib, passed per invocation. %LIB% carries the same trap in the other direction — clang omits its own -libpath: for the VC, UCRT and Windows SDK directories the moment %LIB% is set (same file, if (!GetEnv("LIB") || ...)), so a %LIB% holding only SQLite silently strips the entire platform link path. Only linking needs it (tests and executables); a library build resolves purely through CPATH.

    Spell it /LIBPATH:, never -LIBPATH:. The dash form is swallowed by the driver's GNU-style -L flag: -LIBPATH:C:\…\lib is read as -L with the value IBPATH:C:\…\lib and re-emitted to the linker as -libpath:IBPATH:C:\…\lib. Nothing warns — the only symptom is lld-link: error: could not open 'sqlite3.lib', which reads exactly like a missing library, and -v shows the mangled path only if you look at the linker's own argument list (the -out:… line, not the clang line). MSVC-style flags whose first letter isn't a driver option — /FORCE:MULTIPLE — survive either way, which makes the failure look selective.

The -static-md triplet is deliberate: SQLite links statically against the dynamic CRT, which is what the Swift toolchain uses, so the CRT agrees and no sqlite3.dll has to be staged beside every test binary. CI does the same thing with explicit flags rather than CPATH, so hosted runners stay hermetic (.github/workflows/windows.yml).

The triplet is ours, not vcpkg's: windows/vcpkg-triplets/<arch>-windows-static-md-nucleic.cmake, passed with --overlay-triplets. It is the stock -static-md triplet plus SQLITE_ENABLE_SNAPSHOT. GRDB's system-SQLite path compiles WALSnapshot.swift and DatabaseSnapshotPool.swift unconditionally — it assumes the platform SQLite exposes sqlite3_snapshot_*, which holds for Apple's system SQLite and typical distro builds but not for a stock vcpkg port, where the API is compiled out. Without the define the library resolves and links, then fails with four undefined symbols (sqlite3_snapshot_open/get/free/cmp) out of GRDB — a failure that looks nothing like a missing feature flag. The -nucleic suffix keeps this tree from colliding with a stock <arch>-windows-static-md install in the same vcpkg root.

14.3 Tailnet on Windows: inert by construction, not stubbed

NucleicTailnet compiles on Windows and does nothing there, and that needs no Windows-specific code. The module was already built to work without its binary artifact: TailscaleKit is a locally built Apple xcframework (scripts/build-tailscalekit.sh), so every reference to it sits behind #if canImport(TailscaleKit). On Windows that is false, which means TailnetSupport.isBuiltIn reports false and every entry point throws TailnetError.notBuiltIn — the same path a Mac takes when the artifact hasn't been built.

So the audit answer is: don't add a Windows stub. TailnetNode and TailnetListener have no unguarded platform code at all (their poll/errno/close calls are all inside the TailscaleKit guards; the socketpair in TailnetListener is only in a doc comment). The one file that genuinely needed Windows work was FDFrameChannel, which lives outside those guards on purpose — it wraps any connected fd — and needed two things:

  • closeDescriptor gained an os(Windows) branch calling the CRT's _close.
  • The DispatchIO fd argument goes through numericCast: dispatch_fd_t is Int32 on POSIX but Int on Windows, and the typealias is not in scope on Linux, so it can't be named.

Windows Tailscale does not integrate as an in-process tsnet node, so a future Windows tailnet backend should implement against this module's existing isBuiltIn / notBuiltIn surface rather than porting the libtailscale fd plumbing.


15. Risks & mitigations

Risk Mitigation
wslc preview churn (API breaks before GA) All wslc calls isolated in brokerd behind our own RPC; NuGet version-pinned per release; broker capabilities hello lets hostd degrade; M1 files gaps early while Microsoft is taking preview feedback.
Internal COM ABI breaks (D13 — wslc.idl states outright that breaking changes are fine, since Microsoft ships both ends together and we are not both ends) The blast radius is one class: WslcFacade, behind IWslc. The broker probes the internal interface at bind time and reports the result in its capabilities hello, so a break costs reattach, stats and the Terminal panel — not the sandbox. WSL version pinned per release alongside the NuGet, and WslcApiDump hashes the pinned wslc.idl/WSLCCompat.idl so drift is caught by a spike run rather than a user.
Swift-Windows toolchain gaps (corelibs Foundation holes, DLL export quirks, NIO/GRDB linkage, FileHandle.bytes-class issues) M0 front-loads discovery; the Linux build's known-gap map is the checklist; CMake fallback documented for the DLL; a WindowsFoundationCompat.swift for point fixes.
9P NTFS bind-mount performance (git/npm in mounted worktrees) — MEASURED on naros: ~1523x, D8 survives §13.3: git status 1,510 ms on the mount vs 65 ms local (23x); writing 2,000 small files 3,864 ms vs 253 ms (15x). An earlier find-based proxy said 164177x and was inflated ~10x — see §13.3 for why. No wslc knob softens it (ContainerVolume has no cache or metadata option), so the mitigation is placement: write-hot dirs (node_modules, build output) on a ContainerNamedVolume, working tree stays on NTFS per D8 since .git must remain host-visible for GitRunner. Still unmeasured: a real npm install, and a much larger repo.
hvsocket-in-container reachability unknown Gateway-TCP is primary precisely because it's provable in M1; hvsocket is upside, not a dependency.
Firewall / networking-mode variance (DNS tunneling, enterprise policy) Baseline measured (§13.3): reachable on a default-policy Windows 11 box with no rule added, via an interface-scoped bind to the WSL gateway. That is one machine, not every machine — enterprise policy remains the variance — so the onboarding connectivity self-test through probeControlPlane and its New-NetFirewallRule remedy stay, now with a known-good baseline to compare against. Note ContainerNetworkingMode is None | Bridged, so the NAT/mirrored matrix this row once assumed does not exist.
Published docs disagreeing with the shipped package (Microsoft Learn's C# sample uses MemoryMB, CmdLine, DeleteContainerFlags; none exist in 2.9.3) Read the metadata, never the docs: windows/spikes/WslcApiDump dumps the real surface and checks every member the facade calls. Run it after any package bump — that is now its whole job. §1.5 records the measured model, not the documented one.
DLL↔C# JSON drift Golden fixtures in fixtures/protocol-abi/ run from both languages on every CI run; DTOs reviewed against WireMessages.swift on protocol version bumps.
App Installer UX limits (no delta updates, prompt fatigue) In-app update check reads the same feed; keep MSIX payload lean (Swift runtime + MinGit are the big items); winget post-beta.
ARM64 tail risks (Go c-shared for windows/arm64, wslc-on-ARM less trodden) ARM64 is in CI from M0 so breakage is visible immediately; tailnet DLL is M5 scope.
CRLF/longpath git edge cases MinGit pinned + core.longpaths/core.autocrlf enforced on managed clones; e2e includes a longpath + LF-sensitive fixture repo.

16. Key file index (for implementing agents)

Purpose Path
Manifest gating to extend Package.swift (:59-70, :219, :266, :352-381, :402)
Stub/shim template Sources/NucleicCore/LinuxSupport.swift
Headless bring-up to factor Sources/nucleicd/Nucleicd.swift
Container policy (port contract) Sources/NucleicCore/Container/ContainerManager.swift
Engine surface to protocolize Sources/NucleicCore/Container/ContainerEngine.swift (:513,:660,:697,:727-:924)
Process handle pattern to mirror Sources/NucleicCore/Container/ContainerizedProcessHandle.swift, Sources/NucleicCore/ProcessHost.swift
Data root / owner-only files (shared seams) Sources/NucleicCore/{NucleicPaths,SecretFile}.swift
Windows platform shims Sources/NucleicCore/Windows/ (see §4.3)
ContainerSpec + constants Sources/NucleicCore/Backend.swift:54
Spec construction Sources/NucleicCore/SessionController.swift:643; Sources/NucleicCore/Project.swift:77
Control plane server + ByteConn seam Sources/NucleicCore/Claude/MCPApprovalServer.swift; Sources/NucleicCore/Claude/ApprovalServerRegistry.swift
Guest bridge containers/nucleic-sandbox/control-bridge.js
Interceptors + hook env Sources/NucleicCore/Container/ContainerEngine+Rootfs.swift:394,:466; Sources/NucleicCore/Container/CommandInterceptor.swift
Backend run loop (container exec path) Sources/NucleicCore/Claude/ClaudeCodeBackend.swift:766-1087
Sync authority + bridge Sources/NucleicCore/Sync/SyncHost.swift, ConnectionHandler.swift, SyncHostBridge.swift; Sources/NucleicCore/AppStore.swift (conforms; wires SyncHost ~:9410)
Wire protocol + client Sources/NucleicProtocol/Sync/{MessageEnvelope,WireMessages,SyncClient}.swift, CBOR/, Noise/, FrameChannel
Renderer template ios/NucleicRemote/NucleicRemote/Models/{RemoteStore,HostConnection}.swift
Transcript projections (shared) ios/NucleicRemote/.../Views/Transcript/{TranscriptProjection,IncrementalTranscriptProjection}.swift (SPM target NucleicRemoteProjection)
Git layer Sources/NucleicCore/Git/{GitRunner,WorktreeManager,GitHubCredentials}.swift
Persistence Sources/NucleicCore/Persistence/GRDBMetadataStore.swift
Intelligence seam Sources/NucleicCore/Intelligence.swift, AFMRequestQueue.swift
wslc API — measured surface + findings docs/WINDOWS_PORT.md §1.5, §13.1; windows/spikes/WslcApiDump/FacadeAssumptions.cs
wslc COM interfaces (open source) microsoft/WSL: src/windows/service/inc/WSLCCompat.idl (stable, SDK-facing) and src/windows/service/inc/wslc.idl (internal, what the CLI uses)
Contracts docs docs/VSOCK_CONTROL_PLANE.md, docs/CONTAINER_ISOLATION.md, docs/NAROS.md, docs/NASH.md, docs/RUNTIME_ARCHITECTURE.md, docs/COVALENCE_RUNNER.md
Release pipeline references scripts/package-app.sh, scripts/release-macos.sh, cloud/nucleic-updates, .github/workflows/{naros,sandbox-image}.yml

External references: WSL container overview · wslc API reference · wslc samples · Swift Windows install · GRDB releases (Windows since 7.10)