182 KiB
WINDOWS_PORT — Nucleic for Windows, full implementation plan
Status: In execution, 2026-07-31 — §12 work items 1–10 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 hardware —
Wslc/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.datais no longer dropped.JSONRPCConnection.RPCError.servergainedkind: String?(populated fromerror.data.kind), andWslcContainerEngine.callMappednow branches on it instead of inferring from which method failed — a guess that was sometimes wrong, e.g. aproc.execrefused because the image cannot drop privileges is notnotRunning. The method-based mapping stays as the fallback for an older broker. Only one call site matched that enum case, and theJSONRPCConnectiontests assert the error type, not its payload, so the added associated value is contained. One thing deliberately NOT done:unsupportedmeans this broker build cannot serve the call as asked — not that it never will — and its useful signal is "retrying will not help".ContainerErrorhas no case carrying that, so it maps to.startFailed(which reads as transient) with the reason in the message. A dedicated.unsupportedcase is the right fix and needs the Darwin definition and itsLinuxSupportmirror 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.
WslcProcessHandleregisters its sinks only afterproc.execreturns, 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.stdoutandproc.exitarrived first androutesilently discarded both — sowait()never returned.runCapturingwas 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 inregister(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.swift —
bringUp(spec:manager:server:) encodes the per-platform ORDER (macOS: ensureRunning →
start(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,
SetConsoleCtrlHandler → stopSyncServer + 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.ensureRunningreadscontainerStatefirst and only creates when the container isabsent, so a vanished container is recreated on next use — it self-heals without a sweep.ContainerManager.reconcile(activeSessions:)only forwards toengine.reconcileDisk(keepNames:)anyway. It would not clear the manager's own stale bookkeeping (physicalNames, theactiverefcounts), 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.swift—pid_t/SIGKILL/SIGTERM/SIGINTas module-internal stand-ins (ucrt has no SIGKILL and nokill), 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.swift—kill(pid:sig:)(SIGKILL →TerminateProcesswith exit code 137 soBackend.isSIGKILLclassifies 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 inChildProcess.initright afterProcess.run()— corelibs exposes neither creation flags nor the process handle), and the Toolhelp /GetProcessTimes/GetProcessIoCountersbackings forProcessHost's three sampling ladders.FilePermissions.swift— protected single-ACE DACL viaSetEntriesInAclW+SetNamedSecurityInfoW, behind the new sharedSecretFile(below).WindowsEnvironment.swift— registryPath(HKLMSession Manager +HKCU\Environment) forLoginShellPATH, plussetenv/unsetenvoverSetEnvironmentVariableW(POSIX-only names thatucrtlacks andNucleicHeadless/RunnerCredentialVault/both hosts call).PowerBlocker.swift—SetThreadExecutionState(ES_CONTINUOUS|ES_SYSTEM_REQUIRED), driven from a 5-second main-actor tick in hostd offstore.localTurnInFlightCount(no SwiftUIonChangeheadlessly). 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 samenucleic.power.blockSleepkey, so one lever governs both.WindowsFileIO.swift—MoveFileExW(REPLACE_EXISTING|WRITE_THROUGH)forCarbonShardStore.atomicWrite: the CRT'srenamefails 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-identicalFileManagercalls they replaced, ~12 credential sites retargeted) andNucleicPaths.swift(one data root:<Application Support>/Nucleicunchanged on macOS/Linux,%APPDATA%\Nucleic<channel>on Windows,NUCLEIC_DATA_DIRoverride honored by every derived path — previously hostd's root governed only the store path it passed tomakeStore).ApprovalServerRegistry,MoveLog/MeshDebugLog,TailnetTransportandLinuxSecretStorenow 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 anos(Windows)arm (its#elsewas the Glibc one, so Windows fell intoGlibc.bind);SessionController'sgetuid()/getgid()useContainerSpec.narosAgentUIDon Windows (no host uid to mirror, and the guest runs under WSL);ChildProcessresolves bare executables over PATH × PATHEXT (no/usr/bin/env);LoginShellPATHsplits/joins on;(splitting a Windows PATH on:shreds everyC:\…entry) and reads the registry instead of probing a shell, whileLoginShellEnvreturns nil (a Windows process is born with the merged machine+user environment — nothing to recover);nucleicdleft the Windows manifest entirely (nonWindowsTargets/nonWindowsProducts— its control endpoint is a BSD-socket listener with aDispatchSourcesignal handler, andswift testbuilds every declared target).FileWatcherfrom 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.swift— divergence from §6, deliberate: the bridge lives inNucleicProtocol, not "in the DLL layer only" as §6 sketched. §6 was written assuming a hand-written per-case JSON mapping; in factClientMsg/HostMsgare alreadyCodable(tagged{"t": …}) and the CBOR wire codec is aCodableimplementation, 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 byNucleicProtocolTests, 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-2001Double(what CBOR already emits, so a date reads identically on both sides — not ISO8601, not Unix epoch);Dataas base64 (the one deliberate divergence from CBOR, which emits a byte array — a 4 KB blob as 4,096 JSON numbers is hostile tobyte[]); non-conforming floats as"Infinity"/"-Infinity"/"NaN"rather than a throw; output.sortedKeys+.withoutEscapingSlashesso 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.jsonbytes the C ABI exposes and the exact wire CBOR as.cbor.hex(64 client kinds + 51 known host kinds;HostMsg.unknownis 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 explicitNUCLEIC_UPDATE_PROTOCOL_ABI=1regeneration mode documented in the fixture README. It lives inNucleicProtocolTests, not a Windows-onlyNucleicProtocolCTeststarget, 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".NIOClientFrameChannelis the Windows/Linux TCP leg: one shared NIO event-loop thread, the existing four-byteWireFraming/FrameAccumulator, and no changes toSyncClientor Noise.np_client_connect_localaccepts hostd's{pid,port,localPSK}rendezvous (alsopskas an alias); remote connect accepts pinned LAN/tailnet TCP and relay descriptors; pairing consumes the existingnucleic://pair?d=…payload.directis 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 underidentityDir; the pin learned at XXpsk0 readiness is committed before thereadystate callback. Entry-point return values report synchronous acceptance/validation (0, or stable-1…-4codes); TCP/Noise outcomes remain asynchronous. - The
SyncClient.Eventmapping is exhaustive at compile time. Every message-shaped event is re-encoded withProtocolJSONand delivered tonp_event_cb; lifecycle events (connecting, transport selection,readywithWelcome+ learned host key, failure, closed) go tonp_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_closecancels/drains that handle's pending work and is a callback barrier, so C# may release its callback context as soon as close returns. TranscriptProjectionABIwraps the unchanged shared incremental projector and maintains events per session acrosssnapshot,events, transcript backfill chunks, and reverts. Eachnp_projection_applyreturns a versioned minimal middle splice{start,deleteCount,items}; irrelevant host messages and idempotent replays return no splices. Render-itemseqis a decimal string so the fullUInt64survives JSON's 53-bit ecosystem.NucleicProtocolCand 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-exportsfound 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, observedNP_INVALID_STATE(-2) for an intent before connect, projected a canonicalpongto{"splices":[],"version":1}, and freed the returned string.NucleicCarbonTestspassed 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 11–13, 15–16. 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.6 → libncursesw 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 ~15–23x — 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.idl — IWSLCSessionManager, 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 hardware — wslc.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) computesonLinuxat manifest-eval time (Package.swift:62-64), dropsdarwinOnlyTargets(:70,:402), conditionalizes dependency lists (:219,:266), and excludes Apple-framework sources fromNucleicCorevia a per-targetexclude:list (:352-381). The manifest is also channel-aware (NUCLEIC_CHANNELenv → product name +NUCLEIC_DEV/CANARY/BETA/RC/STABLEcompile define).- Source-level gating is almost entirely
#if canImport(...)(only 3#if os(...)in all ofSources/:Claude/ClaudeCredentialBroker.swift:360,Codex/CodexCredentialBroker.swift:166,Project.swift:465). Sources/NucleicCore/LinuxSupport.swift(~443 LOC) provides stub actors (ContainerManager,MacVMManagermirrors that throwLinuxUnsupported) plus anos.Loggershim, so shared call sites compile unchanged on non-Darwin. This is the template forWindowsSupport.swift.Sources/nucleicd/(645 LOC:Nucleicd.swift,ControlEndpoint.swift) is a proven headless host overNucleicCorefor Linux (the Covalence Cloud Runner): oneAppStoregraph,startSyncServer(), pairing mint, signal-driven shutdown. This is the template fornucleic-hostd.NucleicProtocolbuilds 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:
- The agent sandbox (
Container/): Applecontainerizationframework, headless Linux microVMs. → This maps to wslc. - Computer-use VMs (
MacVM/): fullVirtualization.frameworkGUI VMs. → Excluded.
Key mechanics that carry over unchanged (all guest-side / protocol-level, OS-agnostic):
- Control plane protocol: host
MCPApprovalServerserves HTTP JSON-RPC atPOST /mcp(Streamable-HTTP MCP:initialize,ping,tools/list,tools/callwith SSE for gated calls), bearer-token multiplexed per session, plus plain JSONPOST /git-event,/gh-event,/command-event,/shell-event(dispatch atMCPApprovalServer.swift:1903-1916). Agent-side MCP config (mcpConfigJSON,:1611) always points athttp://127.0.0.1:9099/mcp. - Guest bridge:
containers/nucleic-sandbox/control-bridge.jslistens on guest loopback9099(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 AppleUnixSocketConfiguration(.into)— the only macOS-specific piece of the entire control plane. A second optional forwarder (proxyBridgePort9098) 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:394and:466), exec the real binary then POST{argv, cwd, exitCode, subcommand, sessionId}toNUCLEIC_GIT_HOOK_URL/NUCLEIC_GH_HOOK_URLwith a bearer token. Optional bash command tracer + batch poster inContainer/CommandInterceptor.swift(also home ofCommandInterceptor.hookEnv(...), the single source of interceptor endpoint env, andagentShellArgv/hostShellArgvfor nash selection). The nash shell (shell/, Rust) reports/shell-event. - Agent run flow (
Claude/ClaudeCodeBackend.swiftrunLoop,:766-1087; Codex/Grok backends identical shape): resolve approval server viaApprovalServerRegistry→containerManager.ensureRunning(cspec)→ mint per-session bearer token →server.start(unixSocketPath: run.container.controlSocketHostPath)→ register handlers keyed by token (register,registerGitReport/GhReport/CommandReport/ShellReport,registerNucleicTools) → argvclaude -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 aProcessHandle(Container/ContainerizedProcessHandle.swiftadapts framework stdio onto the same NDJSONAsyncThrowingStreaminterface a hostChildProcessprovides). - ContainerSpec (
Backend.swift:54):name,image,mounts: [Mount](host/container/readOnly),workdir,env,idleTimeout,claudeHomeStaging/claudeHomeWritable,installGitInterceptor,cpus,memoryGiB,runAsUID(=narosAgentUID501)/runAsGID,controlSocketHostPath,proxySocketHostPath. Constants:controlSocketGuestPath = "/run/nucleic/control.sock",controlBridgePort = 9099,proxyBridgePort = 9098. Built bySessionController.containerSpec()(SessionController.swift:643) fromProject.sandbox(ProjectSandbox,Project.swift:77). - Images: agent rootfs
ghcr.io/abkslm/naros-agent:26.07(narOS, built fromos/by.github/workflows/naros.yml; contract indocs/NAROS.md:naros-initPID 1,agentuid 501, nash as/bin/sh,NAROS_BRIDGE=1supervises control-bridge); legacyghcr.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_TOKENor app GitHub token withread: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 theLinuxSupport.swiftstubs — 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,CmdLineandDeleteContainerFlags, 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.exe — not 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().GetMissingComponentsanswers from OS feature state and works even when the service class is unregistered, which makes it the call that explains every other failure.Session—new Session(SessionSettings(name, storagePath))thenStart(). There is noCreateOrOpen. 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 withERROR_ALREADY_EXISTS(0x800700B7) — so the compat surface cannot re-adopt a running session. Settings:CpuCount,MemorySizeInMB(notMemoryMB),Timeout,EnableGpu,VhdRequirements. Images:PullImage/PullImageAsync(the async form carriesImageProgressthrough a.Progresshandler),GetImages→ImageInfo {Name, Sha256, Size, CreatedTimestamp}, import/load/push/tag/delete.CreateContainer(...),Terminate(),event Terminated(SessionTerminationReason). No container enumeration.Session.Authenticate(uri, user, password) → stringmints the identity tokenPullImageOptions.RegistryAuthtakes — the only producer of it (§13.2).Container— created withContainerSettings(imageName)+Name,HostName(capital N),InitProcess, and reachable only through the handleCreateContainerreturned: theContainerobject itself exposesId/State/InitProcess/Inspect()and noName, so nothing can look one up (§13.2). Settings also carryNetworkingMode(ContainerNetworkingMode=None | Bridged— not NAT/mirrored),Volumes(ContainerVolume(windowsPath, containerPath, readOnly)),NamedVolumes,PortMappings,EnableAutoRemove,EnableGpu,Privileged,DomainName. ThenStart(),Stop(Signal, TimeSpan),Delete(DeleteContainerOption),State,Inspect() → string,CreateProcess(...). No statistics call.Process—container.CreateProcess(settings)thenprocess.Start(): two steps, which is what lets handlers attach before the process runs (a single call would drop the first chunk).ProcessSettings:CommandLine(notCmdLine),EnvironmentVariables(notEnvironment),WorkingDirectory,OutputMode = ProcessOutputMode.Event. No uid/gid and no pty, so exec wraps argv insetpriv/su(§3.2 anticipated this) and the Terminal panel needs the internal interface. EventsOutputReceivedandErrorReceivedare separate (no stderr flag), plusExited; stdin isGetInputStream(), not a write call;Signal(Signal)whereSignalis a named enum limited toNone, SIGHUP, SIGINT, SIGQUIT, SIGKILL, SIGTERM.- Errors arrive as COM HRESULTs, frequently with an empty message. The
WSLC_E_*range is documented inwslc.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, andSources/NucleicCore/JSONRPCConnection.swiftfor 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
Sessionand re-enumerates containers (wslc state is service-backed); hostd then runsContainerManager.reconcile(activeSessions:)against the fresh listing. In-flight execs fail with a distinguishablebrokerLosterror, mapped to the same recovery pathContainerizedProcessHandle.forceCloseStreams()serves today. - Broker sends a
capabilitieshello (wslc/NuGet version, supported features) so hostd can degrade gracefully across preview→GA churn.
2.4 Lifetime & single-instance
Nucleic.exelaunches hostd at startup if not running. Single-instance guard: named mutexLocal\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 (andnucleic-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-263gets 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):
- Add the protocol; conform the Apple
ContainerEngine(mechanical). - Retype
ContainerManager's engine reference (and the few direct engine references inClaudeCodeBackend/CodexAppServerBackend/CodexExecBackend/GrokACPBackend) toany SandboxEngine. - Keep
LinuxSupport.swiftstubs compiling (Linux/nucleicd behavior unchanged — the stubContainerManagerremains the Linux story). - On Windows,
ContainerManager.swiftmoves off the exclude list and compiles against the protocol. Engine files (ContainerEngine*.swift,ContainerizedProcessHandle.swift,MemoryBalloon.swift) stay excluded. AuditOCIArtifact.swift: pure ref-parsing/version helpers move to a shared file ifWslcContainerEnginewants them.
3.2 WslcContainerEngine mapping
New files in Sources/NucleicCore/Container/Windows/ (compiled only on Windows):
WslcBrokerClient.swift— spawnsnucleic-brokerd.exeviaChildProcess, speaks NDJSON JSON-RPC over its stdio, multiplexes requests, supervises (backoff restart, capabilities hello, reattach → notifyContainerManager.reconcile).WslcContainerEngine.swift—SandboxEngineconformance over the broker RPC.WslcProcessHandle.swift— mirrorsContainerizedProcessHandle.swift: adaptsproc.stdout/stderr/exitnotifications onto theProcessHandleprotocol (ProcessHost.swift:44-70):stdoutLines/stderrLinesasAsyncThrowingStream<Data, Error>through the sharedLineSplitter,writeLineframing NDJSON to stdin,wait()→ exit code,sendSignal→ brokerproc.signal,forceCloseStreams()on broker loss,lastActivityNanosbumping,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. |
sampleResourceUsage → ContainerResourceSample |
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; wrapsWslcService.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.inspectcontainer.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}(fromSessionTerminationHandler)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.Event → proc.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) |
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 theonLinuxdetection atPackage.swift:62-64); introducenonDarwin = onLinux || onWindowsand use it whereveronLinuxcurrently gatesdarwinOnlyTargets, Sparkle, and product lists (:70,:219,:266,:402). - Widen
NucleicProtocol's swift-nio / swift-crypto dependency conditions from.linuxto[.linux, .windows]. - NucleicCore
exclude:for Windows = the Linux list (:352-381) minusContainer/ContainerManager.swift(now protocol-typed, compiles on Windows), keeping excluded:Container/ContainerEngine.swift,Container/ContainerEngine+Rootfs.swift,Container/ContainerizedProcessHandle.swift,Container/MemoryBalloon.swift, allMacVM/*Engine*/MacVMManager/MacVMExecChannel/MacVMAgentClient, and the Network.framework Sync files.Container/CommandInterceptor.swift,MacVMSpec.swift,MacVMSurface.swiftstay in (pure values). - Add
Sources/NucleicCore/Windows/**andSources/NucleicCore/Container/Windows/**(excluded on non-Windows), theNucleicProtocolCdynamic-library target/product, and thenucleic-hostdexecutable target. nucleicdis excluded from the Windows manifest (nonWindowsTargets/nonWindowsProducts, item 9). It is the Covalence runner host; Windows hasnucleic-hostd, andControlEndpoint.swiftis a BSD-socket listener whose libc ladder has no Windows arm, with aDispatchSourcesignal handler inNucleicd.swift. Sinceswift testbuilds 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@_cdeclsymbol auto-export proves unreliable, pass a checked-inSources/NucleicProtocolC/exports.defvia-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 |
— | 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 containerchannelSuffixscheme). GRDB WALDatabasePool(Persistence/GRDBMetadataStore.swift:13and the other Application-Support call sites:KeychainOwnedAccess.swift:177,TailnetTransport.swift,Claude/ApprovalServerRegistry.swift,Cast/MeshDebugLog.swift) get a singleNucleicPathshelper rather than N inline lookups. [landed, item 9] —NucleicPaths.dataRoot/.runDirectory/.logsDirectory/.secretsDirectory, withNUCLEIC_DATA_DIRoverriding 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_DIR→NucleicRunner) 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 existingApprovalServerRegistryAPI on Windows (§5). - Git: bundle MinGit (x64/arm64) in the MSIX; configure managed clones with
core.longpaths=trueandcore.autocrlf=false(agents run in Linux containers — the repo must stay LF;.gitattributes-driven projects behave as configured).GitRunner/WorktreeManagercode 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).
MCPApprovalServer.start(host:port:)gains theNIOByteConntransport (§4.3), bound only to the WSL-facing interface address (never 0.0.0.0).WslcContainerEngine.ensureRunningreturns the gateway address the broker reports — this is exactly thehostGatewayvalue the macOS engine already returns, so the contract doesn't change.control-bridge.jsgets a small additive branch: whenNUCLEIC_CONTROL_HOST/NUCLEIC_CONTROL_PORTenv are present, forward guest-loopback9099to that TCP endpoint instead of the relayed UDS (keep the UDS branch for macOS). Same for the proxy bridge (9098→NUCLEIC_PROXY_HOST/PORT).- 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.
- 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
probeControlPlaneseam, with an elevated one-shotNew-NetFirewallRuleremediation script if the default-deny case is hit.
Superseded in part by D13 (§13.1).
IWSLCVirtualMachine::GetIdsupplies the VM GUID anAF_HYPERVbind needs — the missing piece that made hvsocket "upside" below. The ordering should invert at M1 (b): hvsocket primary, gateway TCP fallback. Note also thatContainerNetworkingModeisNone | Bridged, not the NAT/mirrored pair item 4 assumes, and no API surfaces a gateway address (it comes fromGetAdaptersAddressesovervEthernet (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 fornucleic_protocol.h;HostMsg/ClientMsgDTOs 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— theRemoteStoreanalog:ObservableObjectprojection (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 → brokercomponents.*), 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)
- Renderer launches →
HostdLauncherstarts hostd → hostd starts broker → brokercomponents.missing(). - If WSL/wslc components missing → guided install page (elevated
wsl --install/ component install via broker), progress streamed, reboot handling. - Broker
session.ensure→image.pullnaros-agent (progress → existingcontrolDownloadProgressUI path). - Control-plane connectivity self-test (
probeControlPlane) with actionable remediation (firewall script) on failure. - Agent CLI auth: Claude/Codex/Grok OAuth flows (loopback listener, DPAPI custody) — same UX as macOS Settings.
- 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; newwindows/scripts/build-libtailscale-windows.ps1pinned to the same commit asscripts/build-tailscalekit.sh. Sources/NucleicTailnetgains 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-backedFrameChanneltransport works over Winsock fds.- Absent DLL → existing
isBuiltIn == falsepath (feature shows "not built in").
9.2 On-device AI (D6)
Intelligence.swiftalready has a provider seam with heuristic fallbacks. Add aWindowsFoundryIntelligenceProvider: Phi Silica is WinRT-only, so hostd routes requests to a C# sidecar — reuse brokerd with theai.generateRPC (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.ensure→startSyncServer()→ local renderer listener (loopback TCP, port 0, Noise XXpsk0 with PSK auto-approval — the local analog ofmeshAutoApprovePairing,Nucleicd.swift:101) → LAN listener + DNS-SD advertise → relay/tailnet transports per settings. PowerBlockerheld while sessions active. Windows Job Object on brokerd so it dies with hostd.- Transports env default:
NUCLEIC_SYNC_TRANSPORTS=lan,relay(+tailnetwhen configured) vs nucleicd's relay-only default.
12. Work breakdown
Progress: items 1–10 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 11–16 remain (item 14 has build-script/CI design groundwork, but the MSIX/release implementation is not done).
Ordered; items 1–4 are Darwin-first refactors verifiable by the existing macOS suite and safe to start immediately on macOS hardware.
- Manifest & gating —
Package.swiftonly:onWindows/nonDarwin, widened platform conditions, Windows exclude list, new targets. - PortableLogging — lift the Logger shim from
LinuxSupport.swift;WindowsSupport.swiftstubs. - SecretStore seam — protocol + Keychain backend + retarget ~14 files (§4.2); DPAPI backend can land later with the Windows build.
- SandboxEngine seam — protocol +
ContainerEngineconformance + retypeContainerManagerand backend references (§3.1). macOS tests must stay green. - Broker —
windows/NucleicBrokerfull RPC surface (§3.3) + fake-IWslctests. Done for the compat surface: RPC surface, tests (18/18), andWslcFacade.csrewritten 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-callvtable check. - Swift wslc client —
WslcBrokerClient/WslcContainerEngine/WslcProcessHandle+ tests against a scripted fake broker exe (mirror thefake-claudeadapter-contract pattern). - Control plane —
NIOByteConn, gateway plumbing throughensureRunning,control-bridge.jsTCP branch (guest contract unchanged). - hostd —
NucleicHeadlessfactor-out;Sources/nucleic-hostd/Hostd.swift(§11); NIO LAN transport +WindowsDNSSD. - Windows shims —
WindowsSignals,ProcessTree(+ Job Objects),FilePermissions(+ sharedSecretFile),WindowsEnvironment(registry PATH,setenv),WindowsFileIO,PowerBlocker,LoginShellEnv/PATHlegs,NucleicPaths. (FileWatcherturned out not to be needed — §4.3.) - Protocol DLL — target + C header + JSON bridge + golden fixtures + Swift tests.
- Interop + renderer core —
NucleicProtocol.Interop,HostConnection.cs,RendererStore.cs,HostdLauncher, onboarding. - Screens — Home → SessionDetail/Transcript → Composer → Approvals → Projects/ Settings → Panels (Terminal, Diff, Build&Run, Todos) → RemoteAccess/host picker.
- Remote parity — DNS-SD advertise + QR (reuse the
mintPairingCode()path), relay (portable already), tailnet DLL +NucleicTailnetWindows leg. - Packaging/release — MSIX projects, appinstaller templates,
cloud/nucleic-updates/win/<channel>/latestroutes,release-windows.ps1, Trusted Signing. - naros multi-arch — extend
os/+.github/workflows/naros.ymlto push an amd64+arm64 manifest list to GHCR (D10). - 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
ProcessSettings → setpriv 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 1–9); 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:
IWSLCVirtualMachineis produced only byIWSLCVirtualMachineFactory::CreateVirtualMachine, and that factory is an input the SYSTEM service passes intoIWSLCSession::Initialize/IWSLCSessionFactory::CreateSession. Nothing a client can reach — notIWSLCSessionManager, notIWSLCSession— hands one back. So a client cannot callGetId, 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 COM — IWSLCSession::ListContainers |
§2.3's reconcile has no compat-surface equivalent. Returns port mappings alongside, which the compat surface never exposes. |
container.stats |
internal COM — IWSLCContainer::Stats (JSON) |
Feeds ContainerResourceSample. A cgroup read via runCapturing is the escape hatch if this one call proves unstable. |
| session + container reattach | internal COM — IWSLCSessionManager::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 COM — IWSLCProcess::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 COM — IWSLCVirtualMachine::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
IWslcmoves.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
capabilitieshello (§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/WslcApiDumpgrows an IDL-hash check against the pinned revision ofwslc.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.
MarshallingMeasured instead (§13.2): hand-writtenwslc.idlfrom C# is real work —size_isarrays,system_handle, unions — so a small C++/WinRT shim inside the broker is likely cheaper and safer than hand-writtenComImportinterop.ComImportworks.IWSLCSessionManagerbinds and calls correctly from C# with[PreserveSig]andIntPtrplaceholders 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
-
No container enumeration in the SDK.
SessionexposesCreateContainerand nothing that lists containers; the native header has noWslcListContainerseither. Resolved by the CLI arm above (wslc container ps). What still needs a live answer is the session half: whethernew Session(settings)with an existing name attaches or throws (Error.SessionReservedhints at the latter), and whetherWSLC_CONTAINER_START_FLAG_ATTACH— native-only, unexposed in C# — is the in-SDK reattach primitive. -
No per-container statistics. There is no
GetStatistics()—ContaineroffersId,State,InitProcessandInspect()(a string). Socontainer.stats→ContainerResourceSamplehas no source, andsampleResourceUsagemust instead execcat /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. -
No pty.
ProcessSettingshasCommandLine,EnvironmentVariables,WorkingDirectoryandOutputMode— noTerminal, no resize, and noUserId/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 underscript/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.
- §7's Terminal panel loses
§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 fromnew Session(new SessionSettings(name, storagePath))+Start(), exactly as transcribed. - The
SessionCONSTRUCTOR IS LAZY. A secondSessionwith 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 secondStart()fails withERROR_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 needIWSLCSessionManager::OpenSessionByName/EnterSessionon the internal interface. D13's reattach row is confirmed on hardware, not merely inferred from an IDL.- Note the code:
ERROR_ALREADY_EXISTS, notWSLC_E_SESSION_RESERVED(0x80040607). The reserved code exists inwslc.idlbut evidently means something narrower — do not key on it. - A read on an unstarted
SessionthrowsE_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:
-
Containerhas noName, andSessionhas noGetContainers(). §13.1 recorded "no container enumeration"; the sharper fact is that a container is reachable only through the handleCreateContainerreturned.ContainerexposesId,State,InitProcessandInspect()— nothing to match a name against. So the facade keeps its own name→handle roster, andcontainer.listanswers from that roster rather than from the service. Two consequences, both now reported throughcapabilitiesinstead 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 asContainerManager.reconcilecan tell. This is the sharp edge of the D13 reattach gap, and it is worse than "no enumeration" sounded. -
Session.Authenticate(uri, username, password) → stringis whereRegistryAuthcomes from. §13.1 correctly recorded thatPullImageOptions.RegistryAuthis a plain string rather than a credentials object, but not what produces the string. It is this call — so a private GHCR pull isAuthenticate("https://ghcr.io", user, token)then assign. Without it the field has no producer andimage.pullwith auth cannot work at all. -
ImageInfo.Sha256is anIBufferof raw bytes, not a string, so the facade hex-encodes it into thesha256:<hex>digest the rest of Nucleic speaks.ImageInfo.SizeisUInt64. -
The package's own
libfolder lies about its targeting pack, and it is a hard build error. The assembly ships underlib/net8.0-windows10.0.19041.0/, and the csproj pinnednet9.0-windows10.0.19041.0deliberately, reasoning that matching the folder avoided a needless targeting-pack requirement. Butwslcsdkcs.dllis itself compiled againstMicrosoft.Windows.SDK.NET10.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 is10.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-onlyWslcApiDumpspike 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:
IWSLCVirtualMachineis 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.IWSLCSessionis refused by the manager, which is expected and not a gap: a session is returned byOpenSessionByName/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 3–6 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-brokerdshould callCoInitializeSecuritywithRPC_C_IMP_LEVEL_IMPERSONATEonce 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 failsRPC_E_TOO_LATE. Note the ordering hazard: the compat SDK makes COM calls of its own, so this has to precede anyWslcService/Sessionuse inProgram.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 toSession.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 inWslcFacadechanges. Same trick should then apply to containers viaIWSLCCompatContainer(8C3C91FA-D550-41B9-AD9D-23DCBF96F549) andContainer.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:
- Entry point is the compat coclass.
CoCreateInstance(WSLCCompatSessionManager, a9b7a1b9-0671-405c-95f1-e0612cb4ce8f)asking directly forIID_IWSLCSessionManager—wslc.idlhas no activatable class of its own. CoInitializeSecurity(RPC_C_IMP_LEVEL_IMPERSONATE)runs before the first COM call, fromWslcFacade.Create()rather thanProgram.cs, so the ordering constraint sits next to the code that depends on it.Program.cscallsCreate(), nevernew WslcFacade()— the latter compiles fine and silently costs recovery. A per-proxyCoSetProxyBlanketbacks it up, since the process-wide call is order-dependent and the per-proxy one is not.ListContainersis method #19 behind 18 placeholders. Vtable slots are fixed by declaration order, so the four predecessors taking a by-valueWSLCHandleunion are declared asIntPtrand 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 withFlags = All, or stopped containers vanish from the recovery report.- Every failure degrades to "no recovery."
TryBindreturning null is normal on a machine without WSL (REGDB_E_CLASSNOTREGis reported as the onboarding state, not a fault), and the facade then throwssession_existsnamingwsl --shutdownexactly as before.recoverjoins thehellocapabilities 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.
ListContainersisIWSLCSession's 19th method, and the 18 ahead of it include four takingWSLCHandle— a tagged union — by value. None of that matters, because a method never called is never marshalled: declare the predecessors asIntPtrplaceholders and give precise signatures only toOpenContainerandListContainers. The probe already relies on this (it calledListSessionsat slot 6 with placeholders ahead of it). - A successful QI is not a usable object.
IWSLCCompatSessionQI'd fine andFromAbistill 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 (IWSLCContainer → IWSLCCompatContainer,
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.idldeclares interfaces and no activatable class, soCoCreateInstancehad 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. IWSLCVirtualMachineis not reachable from a client — onlyIWSLCVirtualMachineFactory::CreateVirtualMachineproduces 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).GUIDbelieving that to beIID_IUnknown; it is not (it is the CLR's type GUID forSystem.Object,ff77e388-6558-35eb-89cb-5ef50f4b9be2), so every class factory was asked for an interface that does not exist and correctly answeredE_NOINTERFACEat activation. That reads exactly like a real capability answer, which is what made it dangerous. Fixed, plus the probe now shouts if it ever seesE_NOINTERFACEfrom an IUnknown activation — an impossible combination, since every COM object implementsIUnknown— 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:LxssUserSessionInBoxansweredREGDB_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
proc.execresponded after starting the process. A command that finishes instantly (echo) putproc.stdout/proc.exiton the ordered outbound queue ahead of the response carrying theprocIdthat names them, so a client waiting for that exit waited forever. Fixed by enqueueing the response beforeIWslcProcess.StartAsync— the same reason wslc itself splitsCreateProcessfromStart, applied at the RPC boundary. The mirror-image hazard was then confirmed and fixed inWslcBrokerClient.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.- That fix introduced a double response. When
StartAsyncthen 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 onproc.stderr, thenproc.exitwith 126 (the shell's "found but not executable"). ERROR_ALREADY_EXISTSwas mapped tosession_existsunconditionally. 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 --shutdowninstead of removing one container). Split out asWslcError.AlreadyExists; the session paths catch the code by number before reachingTranslate, 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-initruns as PID 1 and the container stays up. The image carries no default CMD — wslc answersno command specifiedwithout one — which is exactly why both engines name init explicitly (WslcContainerEnginepassesinitArgv: ["/usr/sbin/naros-init"]and setsNAROS_BRIDGE=1).- The
setprivuid drop WORKS:id -u; id -ginside the container returned 501/501. The §3.2 fallback for wslc's missingProcessSettings.UserIdis confirmed on the supported image. - The control plane is reachable from naros using
nodeas the client — which is the real one, sincecontrol-bridge.jsis node. The host sawPINGand 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-NetFirewallRuleremedy 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,471–1,563) | 65 ms | 23x |
write 2,000 small files (the npm install shape) |
3,864 ms | 253 ms | 15x |
So the honest figure is ~15–23x, 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 aContainerNamedVolume. 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 statusat 1.5 s is a pause, not a blocker, and keeping it host-visible is what letsGitRunner/WorktreeManagerwork unchanged — which is D8's whole point..gitcannot 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 thanfindwas.
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.ps1interactively, 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+NucleicCarbonTestson 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; SwiftWslcBrokerClienttests against a scripted fake broker exe (mirrors thefake-claudeadapter-contract pattern). - M2 gate (self-hosted runner):
nucleic-smokee2e — 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--globalfor the service account;--systemas well saves surprises when you reproduce a failure by hand.) - .NET 9 SDK for the broker leg — otherwise
actions/setup-dotnetre-downloads it every run, which works but is pure wall-clock. - Nothing else. The Swift toolchain is installed per run by
gha-setup-swiftfrom thewindows/props/swift-version.txtpin, and vcpkg is cloned intoRUNNER_TOOL_CACHEon 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_dispatchinstead of every PR. - No incremental builds by default.
actions/checkoutrunsgit clean -ffdx, wiping.build/each run. Settingclean: falseon 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-ssl → Joannis/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:
-
CPATHfor headers. clang appends it to the header search path and still auto-detects the MSVC and Windows SDK headers.-Persistwrites it to your user environment, after which a bareswift build --target NucleicCoreresolvessqlite3.hwith 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:forsqlite3.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 throughCPATH.Spell it
/LIBPATH:, never-LIBPATH:. The dash form is swallowed by the driver's GNU-style-Lflag:-LIBPATH:C:\…\libis read as-Lwith the valueIBPATH:C:\…\liband re-emitted to the linker as-libpath:IBPATH:C:\…\lib. Nothing warns — the only symptom islld-link: error: could not open 'sqlite3.lib', which reads exactly like a missing library, and-vshows 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:
closeDescriptorgained anos(Windows)branch calling the CRT's_close.- The
DispatchIOfd argument goes throughnumericCast:dispatch_fd_tisInt32on POSIX butInton 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: ~15–23x, 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 164–177x 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)