From 80f28177d1511e463fdb699e9c218ffbbca96d4b Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:38:11 +0100 Subject: [PATCH 1/3] Add fail-closed WSL host boundary --- README.md | 5 +- docs/architecture.md | 10 ++- docs/security-model.md | 6 ++ docs/wsl.md | 49 ++++++++++++ exitcode_test.go | 8 ++ internal/hostenv/hostenv.go | 127 +++++++++++++++++++++++++++++++ internal/hostenv/hostenv_test.go | 90 ++++++++++++++++++++++ main.go | 9 +++ main_test.go | 42 ++++++++++ 9 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 docs/wsl.md create mode 100644 internal/hostenv/hostenv.go create mode 100644 internal/hostenv/hostenv_test.go diff --git a/README.md b/README.md index 9550c6d..729ea7f 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ real Linux CLI/runtime in an ephemeral container |---|---| | Windows 10/11 + Docker Desktop (Linux containers) + PowerShell | **Supported** — this is the validated configuration | | cmd.exe invocation of shims | Works for the common cases; less battle-tested than PowerShell | +| WSL2 | **Not yet supported.** The selected native-Linux frontend now has an explicit fail-closed runtime boundary; config/shim/state implementation and real Docker Desktop WSL qualification remain. See [docs/wsl.md](docs/wsl.md) | | Linux / macOS hosts | **Not supported.** The program is Go and cross-compiles, but shim installation, path mapping and doctor checks are Windows-specific | | Windows containers | Not supported; images are Linux images | @@ -837,7 +838,9 @@ benchmark methodology and the disposable-container tradeoff are in ## Current limitations -- Windows + Docker Desktop (Linux containers) only. +- Windows + Docker Desktop (Linux containers) only. WSL2 runtime detection is + present, but native WSL execution remains gated until its host layout, state + namespace and Docker Desktop qualification slices land. - First invocation of a tool after `cb lock` may still need images present locally (`cb lock` pulls them; `cb self-test` never pulls). - Container startup adds latency compared to native binaries (typically diff --git a/docs/architecture.md b/docs/architecture.md index 2080610..98c3369 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -213,7 +213,7 @@ for orientation, not a claim that every package depends on every package in the tier below it; see the exact edges further down for that): ``` -main argv[0] dispatch, subcommand switch, version, usage, +main argv[0] dispatch, host boundary, subcommand switch, version, usage, exit codes + fatalf/osExit, withMutationLock's signal wrapper ↓ internal/cli setup, install, add, expose, unexpose, uninstall, inspect, @@ -239,21 +239,23 @@ internal/registry Tool/Registry, TOML parser, defaults, registry file internal/toml the shared TOML subset lexer (leaf) internal/atomicio crash-safe write + .bak recovery (leaf) internal/mutationlock the registry mutation lock primitive (leaf) +internal/hostenv Windows/WSL/Linux runtime classification (leaf) ``` The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`, project-internal imports only: ``` -main -> cli, diag, dockerrun, mutationlock, registry, state -cli -> atomicio, diag, dockerrun, lockfile, pathmap, registry, toml +main -> cli, diag, dockerrun, hostenv, mutationlock, registry, state +cli -> atomicio, diag, dockerrun, lockfile, pathmap, registry, statearchive, toml diag -> dockerrun, dockervol, lockfile, pathmap, registry dockerrun -> dockervol, lockfile, pathmap, registry state -> dockervol, pathmap, registry +statearchive -> dockervol, pathmap lockfile -> atomicio, registry, toml pathmap -> registry registry -> atomicio, toml -atomicio, dockervol, mutationlock, toml -> (leaves) +atomicio, dockervol, hostenv, mutationlock, toml -> (leaves) ``` Notably: `lockfile` and `pathmap` both depend on `registry` directly, not on diff --git a/docs/security-model.md b/docs/security-model.md index 6e7074c..e6856e2 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -43,6 +43,12 @@ readable, and dangerous to let others edit. - **Fail-closed configuration.** Unknown registry keys, duplicate tool sections, newer schema versions, incomplete lock entries, and registry-image-not-in-lock all refuse to run rather than guess. +- **Fail-closed host boundary.** Non-bootstrap work currently runs only in a + native Windows process. Windows binaries launched through detected WSL + interoperability, WSL1, recognized-but-not-yet-enabled native WSL2, + standalone Linux and other hosts refuse before registry or Docker work. + WSL2 classification requires Microsoft WSL2 kernel markers; environment + variables alone cannot turn ordinary Linux into a supported host. - **Reserved shim names.** Tool names that would collide with `cb` itself or Windows device names (`con`, `nul`, `com1`, …) are rejected at validation, as are versioned management-binary names beginning with `cb-v` plus a digit. diff --git a/docs/wsl.md b/docs/wsl.md new file mode 100644 index 0000000..74299db --- /dev/null +++ b/docs/wsl.md @@ -0,0 +1,49 @@ +# WSL2 frontend boundary + +ContainerBin's selected WSL model is a native Linux `cb` binary and native +Linux shims inside one WSL2 distribution, using Docker Desktop's supported WSL +integration. A Windows `cb.exe` launched through WSL interoperability is not the +WSL frontend, and standalone Linux remains a separate, demand-gated product. + +This first implementation slice establishes the runtime boundary only. It does +not publish a Linux artifact or enable WSL execution yet. Until the remaining +layout, namespace and Docker qualification slices land, non-bootstrap commands +fail closed on every host except native Windows. + +## Runtime classification + +- Native Windows is the currently supported frontend. +- A Windows process with `WSL_INTEROP` or `WSL_DISTRO_NAME` is classified as + Windows-through-WSL interoperability and rejected. +- A native Linux process is classified as WSL2 only when + `/proc/sys/kernel/osrelease` contains both the Microsoft and WSL2 markers. + `WSL_DISTRO_NAME` is then required so later state identity cannot silently + collapse multiple distributions together. +- A Microsoft WSL kernel without the WSL2 marker is classified as WSL1 and + rejected. Other Linux kernels are standalone Linux and rejected. +- `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; + they perform no Docker or registry mutation and return before host enforcement. + +Environment variables alone never promote an ordinary Linux kernel to WSL2. +Custom kernels that remove the Microsoft WSL2 identity markers fail closed; +Docker Desktop also documents custom WSL kernels as unsupported. + +## Required before WSL execution can be enabled + +Later reviewable slices must still implement and qualify all of the following: + +1. native config, lock, binary and symlink locations with Linux ownership and + permission checks; +2. distribution-scoped shared/project volume identities, with no implicit + equivalence to Windows paths or state; +3. native Linux path, symlink, case, stdin/TTY and signal semantics; +4. Docker Desktop WSL-integration detection without accepting a separate local + Docker Engine by accident; +5. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation + rejection; and +6. real WSL2 + Docker Desktop end-to-end qualification before any support claim. + +Docker's setup contract is documented in its +[WSL2 backend guide](https://docs.docker.com/desktop/features/wsl/): WSL2 +integration must be enabled for the selected distribution, and Docker recommends +keeping bind-mounted project files in the Linux filesystem where practical. diff --git a/exitcode_test.go b/exitcode_test.go index 53228b4..149dc3b 100644 --- a/exitcode_test.go +++ b/exitcode_test.go @@ -58,6 +58,14 @@ func TestSubprocessExitCodes(t *testing.T) { cbPath := buildTestCb(t) workDir := t.TempDir() + if runtime.GOOS != "windows" { + unsupportedDir := filepath.Join(workDir, "unsupported-host") + if err := os.MkdirAll(unsupportedDir, 0755); err != nil { + t.Fatalf("mkdir workdir: %v", err) + } + runExitTest(t, cbPath, []string{"doctor"}, exitCbFailure, unsupportedDir) + return + } tests := []struct { name string diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go new file mode 100644 index 0000000..079205a --- /dev/null +++ b/internal/hostenv/hostenv.go @@ -0,0 +1,127 @@ +// Package hostenv classifies the host process boundary before ContainerBin +// reads configuration or performs Docker/filesystem work. +package hostenv + +import ( + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" +) + +const maxKernelReleaseSize = 4 << 10 + +type Kind string + +const ( + WindowsNative Kind = "windows-native" + WindowsWSLInterop Kind = "windows-wsl-interop" + WSL2Native Kind = "wsl2-native" + WSL1Native Kind = "wsl1-native" + LinuxNative Kind = "linux-native" + Unsupported Kind = "unsupported" +) + +type Runtime struct { + Kind Kind + GOOS string + KernelRelease string + Distro string +} + +// Current returns a conservative classification of the current process. WSL2 +// must identify itself through the Microsoft WSL2 kernel release; environment +// variables alone never upgrade an ordinary Linux process into WSL support. +func Current() (Runtime, error) { + goos := runtime.GOOS + kernelRelease := "" + if goos == "linux" { + var err error + kernelRelease, err = readBoundedFile("/proc/sys/kernel/osrelease") + if err != nil { + return Runtime{}, fmt.Errorf("read Linux kernel release: %w", err) + } + } + return classify(goos, kernelRelease, os.Getenv("WSL_DISTRO_NAME"), os.Getenv("WSL_INTEROP")), nil +} + +// RequireFrontend enforces the currently shipped host boundary. Native +// Windows is supported. WSL2 is recognized explicitly but remains gated until +// its config, shim, state-namespace and Docker integration slices have landed. +func RequireFrontend() error { + return requireFrontend(Current()) +} + +func requireFrontend(info Runtime, probeErr error) error { + if probeErr != nil { + return fmt.Errorf("cannot prove a supported host runtime: %w", probeErr) + } + switch info.Kind { + case WindowsNative: + return nil + case WindowsWSLInterop: + return errors.New("Windows cb.exe launched through WSL interoperability is unsupported; run cb from Windows, or use the native WSL frontend after it is released") + case WSL2Native: + if info.Distro == "" { + return errors.New("native WSL2 was detected but WSL_DISTRO_NAME is unavailable, so distribution identity cannot be proven") + } + return fmt.Errorf("native WSL2 distribution %q was detected, but the WSL frontend is not enabled in this release", info.Distro) + case WSL1Native: + return errors.New("WSL1 is unsupported; the planned native frontend requires WSL2 and Docker Desktop WSL integration") + case LinuxNative: + return errors.New("standalone Linux hosts are unsupported; Linux execution is limited to the planned native WSL2 frontend") + default: + return fmt.Errorf("host operating system %q is unsupported", info.GOOS) + } +} + +func classify(goos, kernelRelease, distro, interop string) Runtime { + info := Runtime{ + GOOS: strings.TrimSpace(goos), + KernelRelease: strings.TrimSpace(kernelRelease), + Distro: strings.TrimSpace(distro), + } + switch info.GOOS { + case "windows": + if strings.TrimSpace(interop) != "" || info.Distro != "" { + info.Kind = WindowsWSLInterop + } else { + info.Kind = WindowsNative + } + case "linux": + kernel := strings.ToLower(info.KernelRelease) + switch { + case strings.Contains(kernel, "microsoft") && strings.Contains(kernel, "wsl2"): + info.Kind = WSL2Native + case strings.Contains(kernel, "microsoft"): + info.Kind = WSL1Native + default: + info.Kind = LinuxNative + } + default: + info.Kind = Unsupported + } + return info +} + +func readBoundedFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, maxKernelReleaseSize+1)) + if err != nil { + return "", err + } + if len(b) > maxKernelReleaseSize { + return "", fmt.Errorf("%s exceeds %d bytes", path, maxKernelReleaseSize) + } + value := strings.TrimSpace(string(b)) + if value == "" { + return "", fmt.Errorf("%s is empty", path) + } + return value, nil +} diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go new file mode 100644 index 0000000..086eaa8 --- /dev/null +++ b/internal/hostenv/hostenv_test.go @@ -0,0 +1,90 @@ +package hostenv + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestClassify(t *testing.T) { + tests := []struct { + name, goos, kernel, distro, interop string + want Kind + }{ + {name: "windows", goos: "windows", want: WindowsNative}, + {name: "windows distro marker", goos: "windows", distro: "Ubuntu", want: WindowsWSLInterop}, + {name: "windows interop marker", goos: "windows", interop: "/run/WSL/1_interop", want: WindowsWSLInterop}, + {name: "wsl2", goos: "linux", kernel: "6.6.87.2-microsoft-standard-WSL2", distro: "Ubuntu", want: WSL2Native}, + {name: "wsl2 case insensitive", goos: "linux", kernel: "5.15.167.4-MICROSOFT-standard-wsl2", want: WSL2Native}, + {name: "wsl1", goos: "linux", kernel: "4.4.0-19041-Microsoft", want: WSL1Native}, + {name: "standalone linux ignores env alone", goos: "linux", kernel: "6.12.0-generic", distro: "Ubuntu", interop: "/run/WSL/1_interop", want: LinuxNative}, + {name: "darwin", goos: "darwin", want: Unsupported}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := classify(tc.goos, tc.kernel, tc.distro, tc.interop) + if got.Kind != tc.want { + t.Fatalf("classify() kind = %q, want %q (%+v)", got.Kind, tc.want, got) + } + }) + } +} + +func TestRequireFrontend(t *testing.T) { + tests := []struct { + name string + info Runtime + probeErr error + want string + }{ + {name: "windows native", info: Runtime{Kind: WindowsNative}}, + {name: "windows interop", info: Runtime{Kind: WindowsWSLInterop}, want: "Windows cb.exe launched through WSL"}, + {name: "wsl2 missing distro", info: Runtime{Kind: WSL2Native}, want: "distribution identity cannot be proven"}, + {name: "wsl2 gated", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, want: "WSL frontend is not enabled"}, + {name: "wsl1", info: Runtime{Kind: WSL1Native}, want: "WSL1 is unsupported"}, + {name: "linux", info: Runtime{Kind: LinuxNative}, want: "standalone Linux hosts are unsupported"}, + {name: "other", info: Runtime{Kind: Unsupported, GOOS: "darwin"}, want: `operating system "darwin" is unsupported`}, + {name: "probe", probeErr: errors.New("no proc"), want: "cannot prove a supported host runtime"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := requireFrontend(tc.info, tc.probeErr) + if tc.want == "" { + if err != nil { + t.Fatalf("requireFrontend() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("requireFrontend() error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestReadBoundedFile(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good") + if err := os.WriteFile(good, []byte("6.6.0-microsoft-standard-WSL2\n"), 0600); err != nil { + t.Fatal(err) + } + got, err := readBoundedFile(good) + if err != nil || got != "6.6.0-microsoft-standard-WSL2" { + t.Fatalf("readBoundedFile() = (%q, %v)", got, err) + } + + for name, content := range map[string]string{ + "empty": " \r\n", + "oversize": strings.Repeat("x", maxKernelReleaseSize+1), + } { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + if _, err := readBoundedFile(path); err == nil { + t.Errorf("readBoundedFile(%s) succeeded", name) + } + } +} diff --git a/main.go b/main.go index d9df3cf..acd6aed 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/cli" "github.com/AviBackToBlack/container-bin/internal/diag" "github.com/AviBackToBlack/container-bin/internal/dockerrun" + "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/mutationlock" "github.com/AviBackToBlack/container-bin/internal/registry" "github.com/AviBackToBlack/container-bin/internal/state" @@ -26,11 +27,19 @@ var version = "dev" // registry I/O. Production always uses registry.Load. var loadRegistry = registry.Load +// requireHostFrontend is a test seam around the fail-closed host boundary. +// Production always uses hostenv.RequireFrontend. +var requireHostFrontend = hostenv.RequireFrontend + func main() { invoked := invokedName(os.Args[0]) if isManagementInvocation(invoked) && handleBootstrapCommand(os.Args[1:]) { return } + if err := requireHostFrontend(); err != nil { + fatalf("host runtime: %v", err) + return + } reg, cfgPath, err := loadRegistry() if err != nil { diff --git a/main_test.go b/main_test.go index 8a6161c..2ca334e 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "strings" "testing" @@ -36,14 +37,19 @@ func TestInvokedNameIsCaseInsensitive(t *testing.T) { func TestBootstrapCommandsDoNotLoadRegistry(t *testing.T) { oldArgs := os.Args oldLoadRegistry := loadRegistry + oldRequireHostFrontend := requireHostFrontend defer func() { os.Args = oldArgs loadRegistry = oldLoadRegistry + requireHostFrontend = oldRequireHostFrontend }() loadRegistry = func() (registry.Registry, string, error) { panic("bootstrap command attempted to load the registry") } + requireHostFrontend = func() error { + panic("bootstrap command attempted host enforcement") + } tests := []struct { name string @@ -73,6 +79,42 @@ func TestBootstrapCommandsDoNotLoadRegistry(t *testing.T) { } } +func TestHostBoundaryPrecedesRegistryLoad(t *testing.T) { + oldArgs := os.Args + oldLoadRegistry := loadRegistry + oldRequireHostFrontend := requireHostFrontend + oldExit := osExit + defer func() { + os.Args = oldArgs + loadRegistry = oldLoadRegistry + requireHostFrontend = oldRequireHostFrontend + osExit = oldExit + }() + + called := false + requireHostFrontend = func() error { + called = true + return errors.New("unsupported host") + } + loadRegistry = func() (registry.Registry, string, error) { + panic("host boundary attempted to load the registry") + } + type exitCode int + osExit = func(code int) { panic(exitCode(code)) } + os.Args = []string{"cb.exe", "doctor"} + + defer func() { + got := recover() + if got != exitCode(exitCbFailure) { + t.Fatalf("main panic = %v, want exit %d", got, exitCbFailure) + } + if !called { + t.Fatal("host boundary was not called") + } + }() + main() +} + func captureMainStdout(t *testing.T, fn func()) string { t.Helper() f, err := os.CreateTemp(t.TempDir(), "stdout-*.txt") From f08a7019c8d881df54fccb52dd7c48fdce082619 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:19:07 +0100 Subject: [PATCH 2/3] fix: clarify WSL host diagnostics --- docs/wsl.md | 10 +++++++--- exitcode_test.go | 1 + internal/hostenv/hostenv.go | 29 +++++++++++++++++++++++------ internal/hostenv/hostenv_test.go | 8 +++++++- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/wsl.md b/docs/wsl.md index 74299db..63e2bac 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -14,13 +14,17 @@ fail closed on every host except native Windows. - Native Windows is the currently supported frontend. - A Windows process with `WSL_INTEROP` or `WSL_DISTRO_NAME` is classified as - Windows-through-WSL interoperability and rejected. + Windows-through-WSL interoperability and rejected. The diagnostic names the + inherited marker so a stray variable in an otherwise native Windows process + can be found and removed. - A native Linux process is classified as WSL2 only when `/proc/sys/kernel/osrelease` contains both the Microsoft and WSL2 markers. `WSL_DISTRO_NAME` is then required so later state identity cannot silently collapse multiple distributions together. -- A Microsoft WSL kernel without the WSL2 marker is classified as WSL1 and - rejected. Other Linux kernels are standalone Linux and rejected. +- A Microsoft kernel without the WSL2 marker is rejected. Classic Microsoft + kernels are classified as WSL1; `microsoft-standard` kernels are reported as + ambiguous because early WSL2 releases used that form before the WSL2 suffix + became consistent. Other Linux kernels are standalone Linux and rejected. - `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; they perform no Docker or registry mutation and return before host enforcement. diff --git a/exitcode_test.go b/exitcode_test.go index 149dc3b..bc5d89d 100644 --- a/exitcode_test.go +++ b/exitcode_test.go @@ -63,6 +63,7 @@ func TestSubprocessExitCodes(t *testing.T) { if err := os.MkdirAll(unsupportedDir, 0755); err != nil { t.Fatalf("mkdir workdir: %v", err) } + runExitTest(t, cbPath, []string{"version"}, 0, unsupportedDir) runExitTest(t, cbPath, []string{"doctor"}, exitCbFailure, unsupportedDir) return } diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 079205a..8de6a38 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -20,15 +20,17 @@ const ( WindowsWSLInterop Kind = "windows-wsl-interop" WSL2Native Kind = "wsl2-native" WSL1Native Kind = "wsl1-native" + WSLUnrecognized Kind = "wsl-microsoft-unrecognized" LinuxNative Kind = "linux-native" Unsupported Kind = "unsupported" ) type Runtime struct { - Kind Kind - GOOS string - KernelRelease string - Distro string + Kind Kind + GOOS string + KernelRelease string + Distro string + InteropMarkers []string } // Current returns a conservative classification of the current process. WSL2 @@ -62,7 +64,11 @@ func requireFrontend(info Runtime, probeErr error) error { case WindowsNative: return nil case WindowsWSLInterop: - return errors.New("Windows cb.exe launched through WSL interoperability is unsupported; run cb from Windows, or use the native WSL frontend after it is released") + markers := strings.Join(info.InteropMarkers, ", ") + if markers == "" { + markers = "WSL_INTEROP or WSL_DISTRO_NAME" + } + return fmt.Errorf("Windows ContainerBin process inherited WSL interoperability marker(s) %s; this invocation is unsupported; run cb from a native Windows process, or use the native WSL frontend after it is released", markers) case WSL2Native: if info.Distro == "" { return errors.New("native WSL2 was detected but WSL_DISTRO_NAME is unavailable, so distribution identity cannot be proven") @@ -70,6 +76,8 @@ func requireFrontend(info Runtime, probeErr error) error { return fmt.Errorf("native WSL2 distribution %q was detected, but the WSL frontend is not enabled in this release", info.Distro) case WSL1Native: return errors.New("WSL1 is unsupported; the planned native frontend requires WSL2 and Docker Desktop WSL integration") + case WSLUnrecognized: + return fmt.Errorf("Microsoft WSL kernel %q lacks an explicit WSL2 marker, so its generation cannot be proven; this host is unsupported", info.KernelRelease) case LinuxNative: return errors.New("standalone Linux hosts are unsupported; Linux execution is limited to the planned native WSL2 frontend") default: @@ -83,9 +91,16 @@ func classify(goos, kernelRelease, distro, interop string) Runtime { KernelRelease: strings.TrimSpace(kernelRelease), Distro: strings.TrimSpace(distro), } + interop = strings.TrimSpace(interop) switch info.GOOS { case "windows": - if strings.TrimSpace(interop) != "" || info.Distro != "" { + if interop != "" { + info.InteropMarkers = append(info.InteropMarkers, "WSL_INTEROP") + } + if info.Distro != "" { + info.InteropMarkers = append(info.InteropMarkers, "WSL_DISTRO_NAME") + } + if len(info.InteropMarkers) != 0 { info.Kind = WindowsWSLInterop } else { info.Kind = WindowsNative @@ -95,6 +110,8 @@ func classify(goos, kernelRelease, distro, interop string) Runtime { switch { case strings.Contains(kernel, "microsoft") && strings.Contains(kernel, "wsl2"): info.Kind = WSL2Native + case strings.Contains(kernel, "microsoft-standard"): + info.Kind = WSLUnrecognized case strings.Contains(kernel, "microsoft"): info.Kind = WSL1Native default: diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go index 086eaa8..afa84a8 100644 --- a/internal/hostenv/hostenv_test.go +++ b/internal/hostenv/hostenv_test.go @@ -18,6 +18,7 @@ func TestClassify(t *testing.T) { {name: "windows interop marker", goos: "windows", interop: "/run/WSL/1_interop", want: WindowsWSLInterop}, {name: "wsl2", goos: "linux", kernel: "6.6.87.2-microsoft-standard-WSL2", distro: "Ubuntu", want: WSL2Native}, {name: "wsl2 case insensitive", goos: "linux", kernel: "5.15.167.4-MICROSOFT-standard-wsl2", want: WSL2Native}, + {name: "legacy microsoft standard is ambiguous", goos: "linux", kernel: "4.19.128-microsoft-standard", distro: "Ubuntu", want: WSLUnrecognized}, {name: "wsl1", goos: "linux", kernel: "4.4.0-19041-Microsoft", want: WSL1Native}, {name: "standalone linux ignores env alone", goos: "linux", kernel: "6.12.0-generic", distro: "Ubuntu", interop: "/run/WSL/1_interop", want: LinuxNative}, {name: "darwin", goos: "darwin", want: Unsupported}, @@ -30,6 +31,10 @@ func TestClassify(t *testing.T) { } }) } + windowsInterop := classify("windows", "", "Ubuntu", "/run/WSL/1_interop") + if got, want := strings.Join(windowsInterop.InteropMarkers, ","), "WSL_INTEROP,WSL_DISTRO_NAME"; got != want { + t.Fatalf("interop markers = %q, want %q", got, want) + } } func TestRequireFrontend(t *testing.T) { @@ -40,10 +45,11 @@ func TestRequireFrontend(t *testing.T) { want string }{ {name: "windows native", info: Runtime{Kind: WindowsNative}}, - {name: "windows interop", info: Runtime{Kind: WindowsWSLInterop}, want: "Windows cb.exe launched through WSL"}, + {name: "windows interop", info: Runtime{Kind: WindowsWSLInterop, InteropMarkers: []string{"WSL_INTEROP"}}, want: "WSL_INTEROP"}, {name: "wsl2 missing distro", info: Runtime{Kind: WSL2Native}, want: "distribution identity cannot be proven"}, {name: "wsl2 gated", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, want: "WSL frontend is not enabled"}, {name: "wsl1", info: Runtime{Kind: WSL1Native}, want: "WSL1 is unsupported"}, + {name: "unrecognized Microsoft kernel", info: Runtime{Kind: WSLUnrecognized, KernelRelease: "4.19.128-microsoft-standard"}, want: "generation cannot be proven"}, {name: "linux", info: Runtime{Kind: LinuxNative}, want: "standalone Linux hosts are unsupported"}, {name: "other", info: Runtime{Kind: Unsupported, GOOS: "darwin"}, want: `operating system "darwin" is unsupported`}, {name: "probe", probeErr: errors.New("no proc"), want: "cannot prove a supported host runtime"}, From 9767b014441ed10ddc8920c7eaab8a9cad0519cb Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:37:31 +0100 Subject: [PATCH 3/3] fix(host): clarify WSL marker diagnostic --- internal/hostenv/hostenv.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 8de6a38..01aa08e 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -68,7 +68,7 @@ func requireFrontend(info Runtime, probeErr error) error { if markers == "" { markers = "WSL_INTEROP or WSL_DISTRO_NAME" } - return fmt.Errorf("Windows ContainerBin process inherited WSL interoperability marker(s) %s; this invocation is unsupported; run cb from a native Windows process, or use the native WSL frontend after it is released", markers) + return fmt.Errorf("Windows ContainerBin process inherited WSL interoperability marker(s): %s; this invocation is unsupported; run cb from a native Windows process, or use the native WSL frontend after it is released", markers) case WSL2Native: if info.Distro == "" { return errors.New("native WSL2 was detected but WSL_DISTRO_NAME is unavailable, so distribution identity cannot be proven")