Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ real Linux CLI/runtime in an ephemeral container
|---|---|
| Windows 10/11 x64 + 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) |
| Windows 11 ARM64 | **CI-qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, but there is no release artifact or real Docker Desktop ARM64 E2E qualification |
| 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 |
Expand Down Expand Up @@ -937,7 +938,9 @@ benchmark methodology and the disposable-container tradeoff are in

## Current limitations

- Windows x64 + Docker Desktop (Linux containers) only. Windows ARM64 has
- Windows x64 + 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. Windows ARM64 has
native non-Docker CI coverage, but no published artifact or support claim.
- First invocation of a tool after `cb lock` may still need images present
locally (`cb lock` pulls them; `cb self-test` never pulls).
Expand Down
15 changes: 9 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ is configuration (`container-bin.toml`), a generated lockfile
```
NAME.exe (hardlink to cb.exe)
→ argv[0] dispatch main() inspects its own invocation name
→ host runtime boundary reject unsupported frontends before config I/O
→ machine policy load fixed admin path, ownership/version validated
→ registry profile lookup container-bin.toml, schema-validated, fail-closed
→ argv normalization repair PowerShell-split "-opt=" "value" pairs
Expand Down Expand Up @@ -230,7 +231,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, bootstrap self-update selection,
withMutationLock's signal wrapper
↓
Expand Down Expand Up @@ -258,14 +259,15 @@ 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)
internal/selfupdate canonical release selection and read-only plan (leaf)
```

The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`,
project-internal imports only:

```
main -> cli, diag, dockerrun, mutationlock, policy, registry, selfupdate, state
main -> cli, diag, dockerrun, hostenv, mutationlock, policy, registry, selfupdate, state
cli -> atomicio, diag, dockerrun, lockfile, pathmap, policy, registry, statearchive, toml
diag -> dockerrun, dockervol, lockfile, pathmap, policy, registry
dockerrun -> dockervol, lockfile, pathmap, policy, registry
Expand All @@ -275,7 +277,7 @@ lockfile -> atomicio, policy, registry, toml
pathmap -> registry
registry -> atomicio, toml
policy -> toml
atomicio, dockervol, mutationlock, selfupdate, toml -> (leaves)
atomicio, dockervol, hostenv, mutationlock, selfupdate, toml -> (leaves)
```

Notably: `lockfile` and `pathmap` both depend on `registry` directly, not on
Expand All @@ -301,9 +303,10 @@ release workflow inject it with `-ldflags "-X main.version=..."`, so that symbol
path is part of the release contract. Packages that need it take it as a
parameter.

`cb self-update --check` is dispatched before machine policy and registry
loading, like the bootstrap help/version path. Release selection therefore
remains available when either local configuration source is missing or invalid.
After the host runtime boundary is enforced, `cb self-update --check` is
dispatched before machine policy and registry loading. Release selection
therefore remains available when either local configuration source is missing
or invalid without allowing unsupported frontends to perform network work.
`internal/selfupdate` has no project imports and performs only bounded metadata
queries and plan output; downloading, attestation verification and installed-file
replacement remain separate later phases.
6 changes: 6 additions & 0 deletions docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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.
- **Machine policy cannot be redirected or weakened.** A present enterprise
policy is loaded only from the fixed OS path, requires administrator/root
ownership and restrictive permissions, and authorizes the already-resolved
Expand Down
53 changes: 53 additions & 0 deletions docs/wsl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# 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. 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 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.

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.
9 changes: 9 additions & 0 deletions exitcode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ 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{"version"}, 0, unsupportedDir)
runExitTest(t, cbPath, []string{"doctor"}, exitCbFailure, unsupportedDir)
return
}

tests := []struct {
name string
Expand Down
144 changes: 144 additions & 0 deletions internal/hostenv/hostenv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// 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"
WSLUnrecognized Kind = "wsl-microsoft-unrecognized"
LinuxNative Kind = "linux-native"
Unsupported Kind = "unsupported"
)

type Runtime struct {
Kind Kind
GOOS string
KernelRelease string
Distro string
InteropMarkers []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:
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")
}
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:
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),
}
interop = strings.TrimSpace(interop)
switch info.GOOS {
case "windows":
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
}
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-standard"):
info.Kind = WSLUnrecognized
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
}
Loading
Loading