From 41f49245967d35d2168207f37b4595ebc4c0b079 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:17:39 +0100 Subject: [PATCH 1/8] Wire native WSL tool runtime --- README.md | 25 +- docs/architecture.md | 42 +-- docs/roadmap-decisions.md | 15 +- docs/roadmap-implementation-requirements.md | 25 +- docs/security-model.md | 39 +-- docs/shell-contract.md | 2 +- docs/wsl-process-contract.md | 196 +++++++------- docs/wsl.md | 105 ++++---- internal/hostenv/hostenv.go | 15 +- internal/hostenv/hostenv_test.go | 3 +- internal/hostenv/wsl_layout.go | 4 +- internal/lockfile/lockfile.go | 14 + internal/lockfile/lockfile_test.go | 15 ++ internal/wsldocker/create.go | 29 ++- internal/wsldocker/create_test.go | 34 +++ internal/wsldocker/remove.go | 4 +- internal/wsldocker/remove_linux.go | 3 +- internal/wsldocker/remove_test.go | 25 ++ internal/wslfs/command.go | 6 +- internal/wslinstall/install.go | 6 +- internal/wslinstall/install_test.go | 4 +- internal/wslrun/frontend.go | 114 ++++++++ internal/wslrun/frontend_test.go | 104 ++++++++ internal/wslrun/plan.go | 271 ++++++++++++++++++++ internal/wslrun/plan_test.go | 153 +++++++++++ internal/wslrun/run_linux.go | 68 +++++ internal/wslrun/run_other.go | 12 + internal/wslrun/runner.go | 255 ++++++++++++++++++ internal/wslrun/runner_test.go | 257 +++++++++++++++++++ internal/wslrun/terminal_linux.go | 141 ++++++++++ main.go | 30 ++- main_test.go | 58 +++++ 32 files changed, 1832 insertions(+), 242 deletions(-) create mode 100644 internal/wslrun/frontend.go create mode 100644 internal/wslrun/frontend_test.go create mode 100644 internal/wslrun/plan.go create mode 100644 internal/wslrun/plan_test.go create mode 100644 internal/wslrun/run_linux.go create mode 100644 internal/wslrun/run_other.go create mode 100644 internal/wslrun/runner.go create mode 100644 internal/wslrun/runner_test.go create mode 100644 internal/wslrun/terminal_linux.go diff --git a/README.md b/README.md index 8297caf..a513ba9 100644 --- a/README.md +++ b/README.md @@ -71,7 +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 has an explicit fail-closed runtime boundary plus fixed-layout and install/config lifecycle commands; runtime/Docker wiring and real Docker Desktop WSL qualification remain. See [docs/wsl.md](docs/wsl.md) | +| WSL2 | **v2 runtime implemented; release qualification pending.** Native Linux shims use the fixed private WSL layout and Docker Desktop's WSL integration directly. Real WSL2 + Docker Desktop qualification remains before the v2 support claim. See [docs/wsl.md](docs/wsl.md) | | Windows 11 ARM64 | **CI/release-artifact/update-path qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, the release workflow produces a reproducible ARM64 archive, and self-update selects and verifies that archive by `GOARCH`; real Docker Desktop ARM64 E2E qualification remains | | 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 | @@ -924,13 +924,12 @@ a false failure; run `cb setup` to append the current default profiles. ### Native WSL layout and installation -`cb wsl prepare --check` is a read-only exception to the still-gated native -WSL frontend. It derives the fixed distribution-local layout from the current +`cb wsl prepare --check` derives the fixed distribution-local layout from the current Linux account, UID, distribution name and machine identity, validates ownership, permissions, symlink boundaries and filesystem locality, and lists missing directories. `--apply` explicitly creates only those directories and -revalidates the result. It does not install `cb`, create shims or config, access -Docker, or enable ordinary commands. See [docs/wsl.md](docs/wsl.md). +revalidates the result. It does not install `cb`, create shims or config, or +access Docker. See [docs/wsl.md](docs/wsl.md). `cb wsl install --check` adds a read-only plan over the same fixed layout. It loads only the root-owned `/etc/container-bin/policy.toml` policy path and the @@ -949,8 +948,9 @@ replacing foreign objects. A signed-registry policy disables automatic registry creation/upgrades and requires an already provisioned authenticated registry. The bootstrap executable must itself be a bounded, current-user-owned regular non-symlink file with safe executable permissions. This command still does not -contact Docker or enable ordinary WSL tool execution; the runtime and real E2E -gates remain. See [docs/wsl.md](docs/wsl.md). +contact Docker itself. Once install reports `RUNTIME ENABLED`, managed tool +shims perform their own fixed-layout and Docker Desktop proofs at invocation +time; real WSL2 release qualification remains. See [docs/wsl.md](docs/wsl.md). ### Self-update release selection @@ -1110,11 +1110,12 @@ benchmark methodology and the disposable-container tradeoff are in ## Current limitations -- Windows x64 + Docker Desktop (Linux containers) only. WSL2 runtime detection, - fixed-layout preparation and the native install/config/shim lifecycle are - present, but native WSL execution remains gated until runtime, state, Docker - Desktop and real WSL qualification slices land. Windows ARM64 has - native non-Docker CI coverage, but no published artifact or support claim. +- Windows x64 + Docker Desktop (Linux containers) is the currently qualified + release target. The native WSL2 runtime, fixed-layout installation, project + mapping, managed volumes, Engine lifecycle, stdio/TTY, resize, signal and exit + propagation are implemented, but the v2 WSL support claim still requires real + WSL2 + Docker Desktop qualification. Windows ARM64 has native non-Docker CI + coverage and published release artifacts, but no Docker 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). - Container startup adds latency compared to native binaries (typically diff --git a/docs/architecture.md b/docs/architecture.md index 5a62fa3..50acce7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -341,7 +341,7 @@ 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 host classification and gated WSL layout (leaf) +internal/hostenv host classification and fixed WSL layout (leaf) internal/terminal shared stdin/stdout character-device decision (leaf) internal/wslfs native WSL filesystem ownership/mode preflight internal/wslshim native WSL registry-derived shim preflight/mutation @@ -351,6 +351,7 @@ internal/wslpathmap native WSL project argument mapping internal/wsldocker native WSL Docker Desktop integration proof internal/wslvolume native WSL namespaced volume identity/lifecycle and stateful-tool binding planning +internal/wslrun native WSL fixed-layout tool runtime orchestration internal/selfupdate release selection, staging, verification and replacement ``` @@ -358,7 +359,7 @@ The exact import edges, from `go list -f '{{.ImportPath}} {{.Imports}}' ./...`, project-internal imports only: ``` -main -> cli, diag, dockerrun, hostenv, mutationlock, policy, projectconfig, registry, selfupdate, state, wslfs, wslinstall +main -> cli, diag, dockerrun, hostenv, mutationlock, policy, projectconfig, registry, selfupdate, state, wslfs, wslinstall, wslrun cli -> atomicio, diag, dockerrun, dockervol, lockfile, pathmap, policy, registry, statearchive, toml projectconfig -> atomicio, pathmap, policy, registry, toml diag -> dockerrun, dockervol, lockfile, pathmap, policy, registry @@ -376,6 +377,7 @@ wslproject -> hostenv, registry wslpathmap -> registry, wslproject wsldocker -> hostenv wslvolume -> hostenv, registry, wsldocker, wslproject +wslrun -> hostenv, lockfile, policy, registry, terminal, wsldocker, wslfs, wslpathmap, wslproject, wslshim, wslvolume selfupdate -> mutationlock, registry atomicio, dockervol, hostenv, mutationlock, terminal, toml -> (leaves) ``` @@ -425,7 +427,7 @@ composed after `internal/wslfs` validates the same layout's home, intermediate path and filesystem-device boundary. It never replaces or removes foreign objects and never discovers unrelated directory entries. -`internal/wslinstall` composes those two boundaries into the explicitly gated +`internal/wslinstall` composes those two boundaries into the explicit `cb wsl install --check|--apply` lifecycle. Check mode validates the layout before read-only fixed-path policy/registry access and reports the exact registry, binary and shim work without recovering backups. Apply mode prepares @@ -433,10 +435,10 @@ the layout, revalidates it under `main`'s signal-aware mutation lock, recovers or upgrades an unsigned registry at mode `0600` (or requires an authenticated pre-provisioned signed registry), atomically publishes the validated running binary at the fixed path, then reconciles the management and registry-derived -tool symlinks through `internal/wslshim`. It performs no Docker I/O and leaves -ordinary WSL dispatch gated. +tool symlinks through `internal/wslshim`. It performs no Docker I/O; ordinary +managed tool dispatch revalidates the resulting identity in `internal/wslrun`. -`internal/wslproject` is an unexposed profile-aware selector and classifier for +`internal/wslproject` is a profile-aware selector and classifier for native WSL project roots. It applies the registry's nearest/outermost marker policy or an exact trusted overlay root, then proves both the selected root and the starting working directory. Marker names must be single Linux path elements, @@ -449,7 +451,7 @@ DrvFs or WSL virtiofs mount. Custom DrvFs roots, entire-drive roots, ambiguous `/mnt` paths and Windows spellings fail closed. The package preserves the exact Linux spelling and does not translate between Windows and WSL path identities. -`internal/wsldocker` is an unexposed native-WSL detector for Docker Desktop's +`internal/wsldocker` is the native-WSL detector for Docker Desktop's supported distribution integration. It rejects Docker endpoint/TLS/API environment overrides and uses a direct Engine API request on the root-owned, non-world-writable `/var/run/docker.sock`, without loading an ambient Docker CLI @@ -457,8 +459,8 @@ or context. The connected peer must be root and the socket device/inode must be stable across the request. The engine must report the exact Linux Docker Desktop name/OS, a Microsoft WSL2 kernel and Docker Desktop's address label. The probe has fixed time and output bounds. A reachable local or remote Docker -Engine is deliberately insufficient; later frontend wiring must repeat this -proof and retain the explicit Unix endpoint for every Docker operation. Its +Engine is deliberately insufficient; each runtime operation repeats this proof +and retains the explicit Unix endpoint. Its separate attach transport admits only a live-stream POST for an exact full container ID, repeats the complete socket/peer proof, bounds the upgrade and error response, and returns a context-bound duplex stream with explicit TTY @@ -467,14 +469,11 @@ the upgraded connection and unblocks I/O. Sibling proof-bound primitives decode strict non-TTY multiplexed output and perform exact container inspection, wait, TTY resize, signal, start, creation and stopped-container cleanup operations. Creation admits only one canonical project bind, exact namespace-prefixed -volumes and a -fixed unprivileged auto-remove configuration; it generates a unique run label, +volumes and an explicit auto-remove/retention configuration; it generates a unique run label, rejects Engine warnings and re-inspects the stopped container before returning its immutable identity. Cleanup accepts only that identity, re-proves all -ownership labels, refuses a running or non-auto-remove object, deletes without -force and verifies absence. These primitives are not yet wired into an enabled -container lifecycle; terminal event collection, host-signal -interception/forwarding and end-to-end exit propagation remain. +ownership labels, refuses a running object or changed retention mode, deletes +without force and verifies absence. `internal/wslvolume` defines the WSL Docker-volume identity and bounded control lifecycle. A volume name starts with `cb--`; @@ -492,7 +491,18 @@ identity constructed by this package and its complete labels plus local driver/scope. The package also preflights an entire stateful profile's project/shared binding set, re-proves the exact project root before deriving project identities, and ensures each distinct identity only after the complete -plan validates. Container/frontend and state-command wiring remain gated. +plan validates. Tool-time wiring is active; native state commands remain. + +`internal/wslrun` is the native WSL vertical orchestrator. It requires the +fixed layout, private registry, managed binary and exact invoked shim; resolves +images only through the fixed WSL lockfile and machine policy; selects and maps +one proven project; validates the complete command/environment/mount plan before +volume mutation; then composes create, attach, start, wait, resize, signal and +cleanup. It retains the owned container until the wait response records the +exit status, so daemon auto-remove cannot race fast tools. Non-TTY streams use +strict Docker framing; TTY sessions use raw terminal mode, resize events and an +explicit Linux signal-forwarding set. Any runtime failure cancels live I/O, +proof-bound kills the container, waits for stop and performs non-force cleanup. After the host runtime boundary is enforced, `cb self-update` is dispatched before machine policy and registry loading. Release selection therefore remains diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 3c69cc8..894cb47 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -16,7 +16,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | RM-24 Python / uv | **Keep both** | Decision complete. Built-in `python`/`pip` keep the dedicated Python provider; `uv`/`uvx` remain separate opt-in stateful profiles. | | RM-26 Python global CLI exposure | **pipx yes; plain pip expose no** | Completed in PR #74. The separate stateful pipx profile and managed store shipped; project/compat `/venv/bin` remains intentionally unexposed. | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all and explicit binary selection are sufficient. Reopen only for a concrete unmet use case. | -| WSL2 | **Native WSL frontend** | Host boundary shipped in PR #77. Fixed-layout preparation and the native install/config/shim lifecycle are explicitly available, while runtime/Docker wiring and real WSL qualification remain. No Windows↔WSL path/state guessing. | +| WSL2 | **Native WSL frontend** | Fixed-layout install plus ordinary managed-tool runtime/Docker wiring are implemented. Native state commands, integration corpus and real WSL qualification remain. No Windows↔WSL path/state guessing. | | Enterprise policy | **Machine-owned constraint layer** | Foundation shipped in PR #75. Authenticated registry and image-trust follow-ups must extend this boundary and cannot be weakened by lower layers. | | Image trust | **Policy-driven Sigstore/cosign at lock time** | Ready after signed-registry policy. Digest locking remains default where policy permits. Required trust never silently falls back to digest-only. | | Per-project overlays | **Explicit digest-bound, add-only trust model** | Implementation-ready on the merged policy foundation. Initial overlays exclude host mounts, env prefixes and shared cross-project volumes. | @@ -344,21 +344,18 @@ is not completion. layout/state identity are merged in PRs #77 and #83; - explicit read-only/apply Linux ownership, permission and symlink layout preparation plus the fixed-path native install/config lifecycle are - implemented; ordinary runtime integration remains gated; + implemented; ordinary managed tool shims now enter the native runtime; - Docker Desktop WSL integration proof, proof-bound bounded control requests, the separately constrained attach transport, strict raw-stream decoder, exact container inspection, wait, TTY-resize, signal, start, creation and - stopped-container cleanup operations are implemented but not yet wired - into an enabled frontend; terminal event collection, host-signal - interception and forwarding policy, and end-to-end exit-code propagation - remain; + stopped-container cleanup operations are wired with terminal events, + host-signal forwarding, retained-container cleanup and exit propagation; - namespace-prefixed/labeled WSL volume identity plus proof-bound exact inspect/create/remove and namespace discovery are implemented; tool-time - creation and state/GC/backup/restore command integration remain; + creation is wired while state/GC/backup/restore command integration remains; - profile-aware nearest/outermost/trusted project-root selection, canonical project and descendant storage classification (including symlink and - nested-mount rejection), and proof-consuming argument mapping are - implemented but not yet wired into an enabled frontend; + nested-mount rejection), and proof-consuming argument mapping are wired; - project identity and cross-boundary rejection integration tests; - real WSL Docker E2E. diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index a30efa1..2de0769 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -79,7 +79,7 @@ The minimum delivery gate for a code change is: | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | | Image trust | **Online/offline production and runtime authorization implemented / private-registry work remains** | Add an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Installer foundation implemented / runtime qualification remaining** | PRs #77 and #83 shipped the fail-closed host boundary and fixed native layout/state identity; explicit read-only/apply filesystem preparation and the fixed-path native install/config/shim lifecycle are available alongside namespace-prefixed/labeled volume identity with proof-bound exact inspect/create/remove/discovery, Docker Desktop integration proof, bounded control requests, constrained attach, strict raw-stream decoding, exact container inspection, exact context-bound wait, proof-bound TTY-resize, container-signal, exact container-start, container-creation and stopped-container cleanup operations, while command/frontend wiring, terminal event collection, host-signal interception/forwarding policy, exit semantics and real WSL qualification remain | +| WSL2 | **Native tool runtime implemented / state commands and qualification remaining** | The fail-closed host boundary, fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup and exit propagation are wired for managed tool shims. Native state-management commands, integration corpus and real WSL2 + Docker Desktop qualification remain. | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | | Release SBOM | **Conditionally deferred** | Trigger on shipped third-party/runtime dependencies or concrete compliance/consumer demand | | Snyk | **Conditionally deferred** | Trigger only for a real coverage gap plus owner/account/token and triage/outage policy | @@ -605,17 +605,16 @@ PR #77 shipped the fail-closed host runtime boundary and explicit Windows/WSL separation. The fixed native Linux config/shim/state layout can now be checked or prepared explicitly, and `cb wsl install --check|--apply` composes it with fixed-path policy/registry loading, managed-binary installation and -registry-derived management/tool-shim reconciliation without enabling tool -execution. Canonical project storage classification with its proof-consuming -argument mapper, fail-closed Docker Desktop WSL integration proof and a -proof-bound bounded Engine API control-request primitive, constrained attach -transport, strict multiplexed-output decoder, exact context-bound -container inspection, container-wait, container-TTY resize, container-signal, -container-start, container-creation and stopped-container cleanup operations -are implemented but not yet wired into an enabled frontend. Terminal event -collection, host-signal interception and forwarding policy, and end-to-end -exit-code propagation remain. Runtime wiring, argument/process behavior and -real WSL qualification remain. +registry-derived management/tool-shim reconciliation. Ordinary managed tool +execution now composes canonical project storage proof and argument mapping, +namespaced stateful/Python volume creation, fixed-lock image authorization, +Docker Desktop WSL integration proof, create/attach/start/wait/resize/signal/ +cleanup, strict non-TTY output decoding, raw TTY mode, terminal resize events, +host-signal forwarding and exact exit-code propagation. Containers are retained +until wait records the exit status and are then removed through the proof-bound +cleanup path, avoiding an auto-remove race for fast tools. Native state-command +integration, project/cross-boundary integration coverage and real WSL +qualification remain. Implementation must define native config/shim location, Docker endpoint, project identity, named-volume behavior, file permissions, case sensitivity, @@ -726,7 +725,7 @@ in PR #91. 2. Signed-registry enterprise policy. 3. Image trust at lock time, after signed-registry policy merges. 4. Remaining RM-31 real published-release/self-test E2E qualification. -5. Remaining WSL2 runtime/Docker wiring and real E2E. +5. Remaining native WSL state commands, integration corpus and real E2E. 6. RM-30 Authenticode only after certificate/protected-signing prerequisites exist. 7. RM-29 real Windows-on-Arm + Docker Desktop qualification last; do not delay higher-value work for it. diff --git a/docs/security-model.md b/docs/security-model.md index 4439b05..01dbaac 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -91,19 +91,20 @@ readable, and dangerous to let others edit. fingerprint, mechanism and signer/key identity. Missing or stale evidence never falls back to digest-only locking. Private-registry credentials remain excluded until an explicit non-ambient bridge is implemented. -- **Fail-closed host boundary.** Non-bootstrap work currently runs only in a - native Windows process. Windows binaries launched through detected WSL - interoperability, WSL1, ordinary work on 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. The sole - native-WSL exceptions are the explicit `cb wsl prepare --check|--apply` and - `cb wsl install --check|--apply` lifecycles. Preparation validates or creates - only the fixed current-user layout and loads no registry or machine policy. +- **Fail-closed host boundary.** Non-bootstrap work runs only in a native + Windows process or a distribution-identified native WSL2 process. Windows + binaries launched through detected WSL interoperability, WSL1, standalone + Linux and other hosts refuse before registry or Docker work. WSL2 + classification requires Microsoft WSL2 kernel markers and a canonical + `WSL_DISTRO_NAME`; environment variables alone cannot turn ordinary Linux + into a supported host. Native WSL preparation validates or creates only the + fixed current-user layout and loads no registry or machine policy. Installation validates that layout before fixed-path policy/registry access, authenticates signed registries when required, and reconciles only the fixed - managed binary and provenance-checked symlinks. Neither path performs Docker - work or enables tool execution. + managed binary and provenance-checked symlinks. Ordinary managed tool + execution then revalidates that fixed installation and uses the proof-bound + Docker Desktop WSL transport; unsupported native management commands remain + rejected. - **Native WSL installation does not adopt ambient files.** The bootstrap executable is the exact OS-reported running image and must be a bounded, current-user-owned regular non-symlink file with safe executable permissions. @@ -150,22 +151,28 @@ readable, and dangerous to let others edit. requires that same proof immediately before deletion and verifies absence afterward. Prefix/label filters are discovery-only and cannot authorize adoption or mutation. Windows and other WSL scopes remain foreign state. -- **Native WSL run containers are transaction-bound.** The unexposed create +- **Native WSL run containers are transaction-bound.** The create primitive accepts no raw Engine body, endpoint, privilege or Docker-socket mount controls. It admits at most one existing, symlink-free canonical project bind, rejects both fixed Docker-socket spellings and their source ancestors, and freshly proves each named volume's exact local name and complete ownership - labels before use. It sets daemon-side auto-remove and labels each run with a - generated 128-bit identity, exact WSL namespace and tool. The returned full + labels before use. It requires an explicit retention mode and labels each run + with a generated 128-bit identity, exact WSL namespace and tool. The returned full container ID is not exposed until a fresh inspect proves those labels, - stopped state, every attach/stdin flag, requested TTY and auto-remove + stopped state, every attach/stdin flag, requested TTY and retention configuration. Post-create validation failure uses a separate bounded context and re-proves exact ownership before any non-force rollback. Later cleanup accepts only the immutable returned identity, re-proves ownership, refuses a - running or non-auto-remove container, deletes without force or anonymous-volume + running container or changed retention mode, deletes without force or anonymous-volume removal, and verifies absence; a racing auto-remove 404 is accepted only after another inspection proves absence. A malformed create response without a valid full ID fails closed rather than guessing a cleanup target. +- **Native WSL runtime failure cleanup is bounded.** Normal tool runs retain the + owned container until Engine wait captures the exact status, closing the + auto-remove race for fast processes. Stream, resize, forwarding or wait + failure cancels live operations, sends SIGKILL only to the immutable owned + container, waits under a fresh bound and then invokes the same non-force + proof-bound removal. Cleanup errors are never hidden by the original failure. - **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 diff --git a/docs/shell-contract.md b/docs/shell-contract.md index 3019525..a231047 100644 --- a/docs/shell-contract.md +++ b/docs/shell-contract.md @@ -1,6 +1,6 @@ # Windows shell/process compatibility contract -For the separately gated native-WSL frontend, see the +For the separately implemented native-WSL frontend, see the [native WSL process contract](wsl-process-contract.md). Its argv and environment-name rules deliberately follow Linux rather than Windows semantics. diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 691f8ae..0104035 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -1,103 +1,103 @@ # Native WSL process contract -This document defines the process semantics selected for ContainerBin's future -native-Linux frontend inside WSL2. It pins behavior that is portable and -testable before that frontend is enabled. It does **not** enable ordinary WSL -commands, publish a Linux binary, or claim real WSL2 + Docker Desktop -qualification. The host gate in [the WSL boundary](wsl.md) remains closed. - +This document defines the process semantics implemented by ContainerBin's +native-Linux frontend inside WSL2. Managed tool shims are enabled in this tree; +the v2 support claim still requires real WSL2 + Docker Desktop qualification. The corresponding Windows behavior is documented separately in [the Windows shell/process contract](shell-contract.md). -## Argv and paths - -On native Linux, each argument received in `os.Args` is forwarded as the same -distinct string. ContainerBin does not perform the Windows-only PowerShell -repair that joins a registry-declared `path_equals` option ending in `=` with -the next argument. For example, the two arguments `-chdir=` and `project` stay -two arguments under WSL; only Windows can repair that shape to -`-chdir=project`. - -The non-Windows path mapper is an exact argv pass-through and produces no -additional bind mounts. Linux absolute and relative paths, symlinks, case and -filesystem identity must be handled by the native WSL project/layout wiring, -not guessed through Windows path translation. That broader filesystem wiring -and its real Docker tests remain prerequisites for enabling the frontend. - -## Environment names - -Profiles remain allowlist-only: only names selected by `env_names` or prefixes -selected by `env_prefixes` are added to `docker run`. Matching follows the host -operating system: - -- Windows matching is case-insensitive, preserving existing behavior. -- Native Linux/WSL matching is case-sensitive. `PATH`, `Path` and `path` are - distinct names, and a profile declaring one does not silently select another. - -ContainerBin passes the selected name to Docker without copying its value into -the command line. The Docker child inherits the process environment and Docker -resolves the selected value by exact name. - -## Streams, TTY and exit status - -The Docker CLI receives stdin, stdout and stderr directly. ContainerBin adds no -buffering, encoding conversion or line editing. The child also receives the -current process environment unchanged. - -The unexposed native Engine API path has a separate strict decoder for Docker's -non-TTY raw-stream framing. It accepts complete stdout and stderr frames with -zeroed reserved header bytes, routes their payloads without frame-sized -allocation, and treats a clean EOF as valid only between frames. Stdin or -unknown stream identifiers, truncated frames, malformed reserved bytes, and -unsafe daemon-error payloads fail closed. This primitive does not enable the -frontend or change the existing Docker CLI path. - -The same proof-bound path has an explicit container-TTY resize operation. It -accepts only an exact full container ID and positive unsigned 16-bit height and -width values, sends them to Docker's fixed resize endpoint, and accepts only an -HTTP 200 response. It does not inspect the caller's terminal or subscribe to -resize events; later frontend wiring must supply dimensions from a proven TTY -and invoke the operation for each accepted resize event. - -`docker run` always receives `-i`. It additionally receives `-t` only when both -stdin and stdout report character-device mode; either redirection, either stat -failure, or a non-character stream keeps the invocation non-TTY. Stderr does -not participate in that decision. - -A normal child completion returns exit status 0. If the Docker child exits with -a non-zero status, ContainerBin returns that exact child status and no wrapper -error. A failure to start Docker is a ContainerBin infrastructure error; the -internal runner returns status 1 plus the start error, and the top-level command -maps infrastructure failures through ContainerBin's documented exit policy. - -A signal-terminated Unix child is a separate, unresolved case: -`exec.ExitError.ExitCode()` returns `-1`, and the current pre-frontend path would -pass that value to `os.Exit`, surfacing as status 255 rather than a conventional -`128 + signal` status. This is not accepted as the final native-WSL contract. -The real WSL qualification slice must select and test an explicit mapping before -the frontend is enabled. - -These stream, TTY and exit rules are covered by portable tests using a real -child process; they do not require Docker. - -## Signals - -The unexposed native Engine API path has a proof-bound container-signal -operation. It accepts one exact full container ID and one explicit numeric Linux -signal in the `1..64` domain, always supplies Docker's `signal` query parameter -and accepts only HTTP 204. It never relies on the endpoint's default `SIGKILL`. - -This primitive does not choose which host signals to intercept, install signal -handlers or define cleanup ordering. The enabled frontend must make those -policies explicit and qualify them end to end before invoking the operation. - -The tool-run path installs no signal handler and creates no new process group. -On native Linux/WSL, the `cb` process and its `docker` child therefore retain -the operating system's default process-group relationship. ContainerBin does -not synthesize, translate or explicitly forward signals. - -That code-level statement is intentionally narrower than a runtime support -claim. Terminal-generated signal delivery through WSL, Docker Desktop, the -Docker CLI and the container process—and resulting cleanup behavior—must still -be exercised in the real WSL2 + Docker Desktop qualification suite before the -frontend can be enabled. +## Argv, working directory and paths + +Each argument received in `os.Args` remains one distinct string. ContainerBin +does not perform the Windows-only PowerShell repair that joins a declared +`path_equals` option ending in `=` with the next argument. + +Project-mode tools apply the registry's nearest/outermost marker policy (or an +exact trusted project root), prove the current directory and project storage +boundary, and bind that root at `/workspace/project`. Linux absolute paths, +explicit relative paths and registry-forced path positions are mapped only +after the exact descendant is re-proven under that root. External paths, +symlinks, nested mounts, Windows spelling and guessed `/mnt/` equivalence +fail closed. Ambiguous bare arguments and package patterns remain tool syntax. + +Isolated tools use `/root`, create no project bind and preserve argv literally. +They can use only shared state volumes; registry validation already forbids +project state and project-marker policy in isolated mode. + +`host_mounts` is rejected by the WSL runtime. Its registry grammar deliberately +describes Windows drive paths, and interpreting those values as native Linux +paths would violate the no-equivalence rule. + +## Environment + +Profiles remain allowlist-only. Names selected by `env_names` or prefixes +selected by `env_prefixes` are matched case-sensitively against the native Linux +environment. Selected `NAME=value` assignments are copied into the Engine create +request; the Docker CLI and its ambient environment are not involved. Literal +`env_set` assignments override a selected host value of the same exact name, +matching the existing provider precedence. + +Python additionally fixes `VIRTUAL_ENV=/venv` and the provider-owned `PATH`. +Those provider values override a conflicting host or profile value because the +bootstrap depends on the exact `/venv/bin/python` identity. + +## Container and state lifecycle + +The runtime resolves the image against the fixed WSL lockfile and machine +policy, plans the complete mount set, validates the create request, and only +then ensures each exact namespaced volume. Stateful profiles retain their +project/shared declarations. The Python provider uses a case-sensitive +project-scoped venv when a marker is found, a namespace-shared compatibility +venv otherwise, and one namespace-shared pip cache. + +The owned container is created stopped and retained until cleanup. This avoids +daemon auto-remove racing an ultra-short-lived process before its status can be +collected. ContainerBin attaches before start, starts exactly once, begins an +Engine wait, captures the `0..255` status, drains output, and removes the stopped +container through the proof-bound non-force cleanup path. Every control or +stream connection repeats the Docker Desktop WSL socket and peer proof. + +## Streams and TTY + +ContainerBin always attaches stdin, stdout and stderr. Non-TTY stdin is copied +byte-for-byte and half-closed at EOF while output remains open. Docker's strict +raw-stream framing is decoded into the caller's separate stdout and stderr; a +truncated or malformed frame is an infrastructure failure. + +TTY mode is selected only when both stdin and stdout are character devices. +The native terminal enters raw mode, the initial size is applied after start, +and each `SIGWINCH` triggers a fresh positive row/column resize. Docker's TTY +stream is unframed and is written to stdout; terminal state is restored on every +return path. + +The runtime waits up to five seconds for the attach stream to drain after the +Engine reports exit. Failure to drain is an infrastructure error rather than a +silent loss of trailing output. + +## Signals, failures and exit status + +The host intercepts HUP, INT, QUIT, USR1, USR2, TERM, CONT and TSTP and forwards +their exact numeric Linux value to the owned container. `SIGWINCH` is consumed +as a resize event and is not forwarded. KILL and STOP cannot be intercepted. + +The tool's Engine status passes through unchanged, including conventional +signal-derived statuses such as 130 when the container process returns them. +ContainerBin does not invent a second mapping from host signals. + +If attach, output, resize, signal forwarding, wait or the caller context fails +while the container is running, ContainerBin cancels the live operations, sends +SIGKILL through the proof-bound signal endpoint, waits for the retained +container to stop, and then performs proof-bound cleanup. Any cleanup failure is +joined to the original diagnostic. The top level maps infrastructure failures +to ContainerBin's documented exit code 120. + +A failed start response is treated as transport-ambiguous: the Engine may have +accepted the request before the connection failed. Cleanup therefore attempts +the same bounded stop/wait sequence and then a proof-bound non-force removal; +the removal can safely clean an already-stopped container but refuses one that +is still running. + +These semantics have in-process integration coverage. The release gate still +requires real WSL2 + Docker Desktop exercises for piped stdin, split output, +interactive resize, Ctrl-C/termination, fast exit, cleanup and exact exit-code +propagation on both distribution and default Windows-drive projects. diff --git a/docs/wsl.md b/docs/wsl.md index e9684d8..253d9df 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -5,15 +5,16 @@ 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. -The implemented foundation establishes the runtime boundary, fixed native-WSL -layout contract, explicit filesystem preparation and the native install/config -lifecycle. It does not publish a Linux artifact or enable WSL execution yet. -Until the remaining frontend wiring, Docker and qualification slices land, -ordinary commands fail closed on every host except native Windows. +The implementation establishes the runtime boundary, fixed native-WSL layout, +explicit install/config lifecycle and ordinary managed-tool execution through +Docker Desktop's Engine socket. It does not yet make a release support claim: +real WSL2 + Docker Desktop qualification and the remaining native management +state lifecycle still have to land before v2.0.0. ## Runtime classification -- Native Windows is the currently supported frontend. +- Native Windows is the currently qualified release frontend. The native WSL2 + frontend is implemented here but remains qualification-gated for v2.0.0. - 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 @@ -29,9 +30,9 @@ ordinary commands fail closed on every host except native Windows. - `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; they perform no Docker or registry mutation and return before host enforcement. - `cb wsl prepare --check|--apply` and `cb wsl install --check|--apply` are the - only native-WSL management exceptions. They classify the live host themselves - and use only the fixed paths described below; all normal tool and management - execution remains gated. + native-WSL management surface. Managed tool shims are enabled after install; + other `cb` management commands remain explicitly unavailable rather than + falling through to Windows-oriented Docker CLI, path or state behavior. Environment variables alone never promote an ordinary Linux kernel to WSL2. Custom kernels that remove the Microsoft WSL2 identity markers fail closed; @@ -65,9 +66,9 @@ location remains the separate administrator-owned `/etc/container-bin/policy.toml` contract. The filesystem checks are a point-in-time preflight, not a durable path handle. -The installer revalidates the layout under the mutation lock, and shim writes -use descriptor-relative, no-follow traversal. Later runtime and Docker wiring -must preserve the same rule at every mutation boundary. +The installer revalidates the layout under the mutation lock, shim writes use +descriptor-relative, no-follow traversal, and ordinary tool execution repeats +the fixed-layout, registry, shim and Docker identity checks before each run. `cb wsl prepare --check` validates this contract without changing the filesystem and reports every missing required directory. Explicit @@ -111,7 +112,9 @@ managed-binary path with mode `0755`. An already byte-identical target is a no-op. The management shim and every registry-derived tool shim are then created only when missing and fully revalidated. Foreign files, owners, targets or unsafe modes stop the transaction instead of being repaired or replaced. -The command performs no Docker request and does not enable tool execution. +The install command itself performs no Docker request. After a successful +apply and revalidation, its managed tool shims are eligible for ordinary +runtime execution. An interruption before the final binary rename can leave a current-user-owned `.cb-install-.tmp` regular file in the private binary directory. ContainerBin does not sweep filename lookalikes without stronger provenance; @@ -161,7 +164,7 @@ distribution therefore cannot silently adopt existing state. ## Project storage boundary -The unexposed `internal/wslproject` selector applies the profile's shared +The `internal/wslproject` selector applies the profile's shared project-marker defaults and `nearest`/`outermost` policy, or an exact trusted overlay root. It rejects malformed marker names and symlink or special-file markers instead of following them. With no marker, the exact working directory @@ -182,7 +185,7 @@ preserves relative package patterns such as `./...`, maps absolute package patterns, and rejects external, symlinked or cross-mount paths instead of creating implicit mounts or translating Windows spellings. Ambiguous bare arguments remain unchanged so a project entry cannot replace a tool subcommand. -Runtime execution is still gated. +The native runtime consumes this exact proof for every project-mode tool. A WSL-filesystem project root must be on the same filesystem device as the distribution root. A Windows-filesystem project root must be below a proven default @@ -259,30 +262,36 @@ namespace-prefixed name, complete labels, local driver and local scope, so Docke cannot implicitly create or adopt foreign state. Duplicate mount targets, malformed environment entries and implicit privilege/endpoint controls are not representable. Creation fixes all three attach streams, open/one-shot stdin and -daemon-side auto-remove, generates a 128-bit run identity, and labels the +an explicit retention mode, generates a 128-bit run identity, and labels the container with the exact namespace, run and tool ownership. A successful Engine response must contain one full lowercase container ID and no warnings. A fresh inspect must then prove the same ID and labels, stopped state, requested TTY -mode, all attach flags, open/one-shot stdin and auto-remove configuration. A -post-create validation failure uses a fresh bounded cleanup context, re-proves +mode, all attach flags, open/one-shot stdin and retention configuration. The +default primitive retains daemon auto-remove behavior. The runtime instead +retains its owned container until the exact wait response captures the exit +status, avoiding an auto-remove race for very short-lived tools, and then uses +the proof-bound non-force cleanup operation. A post-create validation failure +uses a fresh bounded cleanup context, re-proves the returned ID's complete ownership and performs non-force deletion only when that proof succeeds; an invalid/missing ID fails closed because no safe cleanup target exists. The paired cleanup operation takes only the immutable identity returned by creation. An already auto-removed container succeeds. Otherwise it re-inspects -the exact ID, requires every ownership label and auto-remove configuration, +the exact ID, requires every ownership label and the original retention configuration, refuses a running container, sends `DELETE` with both force and anonymous-volume removal disabled, and verifies absence afterward. If daemon-side auto-remove wins the race between inspection and deletion, a DELETE 404 succeeds only after a fresh proof-bound inspection confirms absence. -The native package also has a strict decoder for non-TTY multiplexed output and -a proof-bound resize operation for one exact full container ID with positive -unsigned 16-bit terminal dimensions. These primitives do not implement -terminal event collection, host-signal interception or -forwarding policy, or end-to-end exit-code propagation. Nothing is wired into -tool execution yet; real WSL2 + Docker Desktop qualification remains mandatory -before support. +The runtime attaches before start, streams stdin with an explicit half-close, +decodes non-TTY stdout/stderr framing, uses raw terminal mode for TTY sessions, +applies the initial size, consumes `SIGWINCH`, and forwards HUP, INT, QUIT, +USR1, USR2, TERM, CONT and TSTP numerically to the exact owned container. It +waits for the Engine exit status, drains output, restores the terminal and +performs proof-bound cleanup; the tool's `0..255` exit code passes through. +Infrastructure or stream failure cancels the live wait, sends SIGKILL through +the same proof-bound transport, waits for stop and then cleans up. Real WSL2 + +Docker Desktop qualification remains mandatory before release support. ## Native WSL volume identity and control lifecycle @@ -309,29 +318,29 @@ on partial-result warnings, duplicates or results outside both namespace filters. Because the Engine applies those label and name filters together, discovery deliberately does not report a same-name foreign volume that omits the namespace label; exact-name inspect or ensure still finds and rejects that -collision. Tool execution plus `cb state`/`cb gc` are not wired to these -primitives yet. - -## Required before WSL execution can be enabled - -The native installer/config lifecycle is now implemented, but execution stays -gated. Later reviewable slices must still implement and qualify all of the -following: - -1. wire the proof-bound volume primitives into tool-time shared/project - creation plus `cb state`, `cb gc`, backup and restore; each consumer must - construct and match the complete distribution/machine/user identity; -2. wire the implemented project-root selector, project boundary and argument - mapper into native tool execution, then complete stdin/TTY and signal - semantics; -3. wire the implemented bounded Docker Desktop control-operation, attach, - create/cleanup, raw-stream decoder, inspect, wait, resize, signal and exact - container-start operations into container lifecycle, then implement terminal - event collection, host-signal interception and forwarding policy, and - exit-code propagation without accepting ambient endpoint overrides; -4. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation +collision. Tool execution now plans the complete state set before mutation, +ensures each exact volume, and passes its complete labels into container +creation. Stateful project/shared profiles and the Python provider's +per-project venv, namespace-shared compatibility venv and pip cache are wired. +`cb state`/`cb gc` and backup/restore remain separate native management work. + +## Remaining before the v2 WSL support claim + +Ordinary managed tool execution is implemented. Release qualification still +requires all of the following: + +1. complete the native state-management subset required for safe supported + cleanup and diagnostics; every consumer must construct and match the complete + distribution/machine/user identity; +2. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation rejection; and -5. real WSL2 + Docker Desktop end-to-end qualification before any support claim. +3. real WSL2 + Docker Desktop end-to-end qualification before any support claim. + +The WSL runtime deliberately rejects `host_mounts`: that registry field uses a +Windows drive-path grammar and silently reinterpreting it as Linux would violate +the no-equivalence contract. Project descendants and managed volumes are the +supported native mount inputs. The runtime likewise uses only the fixed WSL +registry and lockfile and never falls back to executable-relative Windows state. Docker's setup contract is documented in its [WSL2 backend guide](https://docs.docker.com/desktop/features/wsl/): WSL2 diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 370d446..3b122a2 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -49,9 +49,9 @@ func Current() (Runtime, error) { 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. +// RequireFrontend enforces the supported host boundary. Native Windows and a +// distribution-identified native WSL2 process are frontends; Windows interop, +// WSL1, ambiguous Microsoft kernels and standalone Linux remain rejected. func RequireFrontend() error { return requireFrontend(Current()) } @@ -73,13 +73,16 @@ func requireFrontend(info Runtime, probeErr error) error { if strings.TrimSpace(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) + if err := validateDistroIdentity(info.Distro); err != nil { + return fmt.Errorf("native WSL2 distribution identity cannot be proven: %w", err) + } + return nil case WSL1Native: - return errors.New("WSL1 is unsupported; the planned native frontend requires WSL2 and Docker Desktop WSL integration") + return errors.New("WSL1 is unsupported; the 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") + return errors.New("standalone Linux hosts are unsupported; Linux execution is limited to the native WSL2 frontend") default: return fmt.Errorf("host operating system %q is unsupported", info.GOOS) } diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go index 79023d1..2ec2379 100644 --- a/internal/hostenv/hostenv_test.go +++ b/internal/hostenv/hostenv_test.go @@ -49,7 +49,8 @@ func TestRequireFrontend(t *testing.T) { {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 whitespace distro", info: Runtime{Kind: WSL2Native, Distro: " \t"}, want: "distribution identity cannot be proven"}, - {name: "wsl2 gated", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, want: "WSL frontend is not enabled"}, + {name: "wsl2 noncanonical distro", info: Runtime{Kind: WSL2Native, Distro: " Ubuntu"}, want: "distribution identity cannot be proven"}, + {name: "wsl2", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}}, {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"}, diff --git a/internal/hostenv/wsl_layout.go b/internal/hostenv/wsl_layout.go index 34806ab..e10bbf2 100644 --- a/internal/hostenv/wsl_layout.go +++ b/internal/hostenv/wsl_layout.go @@ -17,8 +17,8 @@ const wslNamespaceDomain = "container-bin/wsl2-state/v1\x00" // WSLLayout is the fixed user-local filesystem and Docker-state contract for // one native WSL2 distribution. It is computed without consulting XDG or PATH // environment variables so a launcher or project cannot redirect trust state. -// Ordinary frontend execution remains gated until the later installer, Docker -// integration, path and end-to-end qualification slices land. +// Ordinary frontend execution consumes this identity after the installer and +// filesystem checks revalidate the fixed paths. type WSLLayout struct { Distro string UID uint32 diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go index 64d0fa0..aa019d6 100644 --- a/internal/lockfile/lockfile.go +++ b/internal/lockfile/lockfile.go @@ -605,6 +605,20 @@ func RuntimeImageForTool(t registry.Tool, machinePolicy policy.Policy) (string, return runtimeImageForTool(t, machinePolicy, lf, path) } +// RuntimeImageForToolAt resolves one tool against an explicit frontend-owned +// lockfile path. Native WSL uses this entry point so its fixed private layout +// can never fall back to the executable-relative Windows registry identity. +func RuntimeImageForToolAt(t registry.Tool, machinePolicy policy.Policy, path string) (string, error) { + if path == "" || !filepath.IsAbs(path) { + return "", errors.New("lockfile path must be absolute") + } + lf, err := Load(path) + if err != nil { + return "", fmt.Errorf("lockfile: %w", err) + } + return runtimeImageForTool(t, machinePolicy, lf, path) +} + func runtimeImageForTool(t registry.Tool, machinePolicy policy.Policy, lf *LockFile, path string) (string, error) { if lf == nil { if err := machinePolicy.AuthorizeImage(t.Image, false, false); err != nil { diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go index bee9961..f2c1158 100644 --- a/internal/lockfile/lockfile_test.go +++ b/internal/lockfile/lockfile_test.go @@ -502,6 +502,21 @@ func TestRuntimeImageForToolReportsPolicyBeforeStaleLock(t *testing.T) { } } +func TestRuntimeImageForToolAtUsesExplicitFrontendLockPath(t *testing.T) { + tool := registry.Tool{Name: "demo", Image: "example.com/acme/demo:1", Provider: "stateless"} + path := filepath.Join(t.TempDir(), "container-bin.lock") + got, err := RuntimeImageForToolAt(tool, policy.Policy{}, path) + if err != nil { + t.Fatal(err) + } + if got != tool.Image { + t.Fatalf("RuntimeImageForToolAt() = %q, want %q", got, tool.Image) + } + if _, err := RuntimeImageForToolAt(tool, policy.Policy{}, "relative.lock"); err == nil || !strings.Contains(err.Error(), "must be absolute") { + t.Fatalf("relative lock path error = %v", err) + } +} + func TestLoadLockFile_RecoversFromBackup(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "container-bin.lock") diff --git a/internal/wsldocker/create.go b/internal/wsldocker/create.go index 0d03491..7ecba87 100644 --- a/internal/wsldocker/create.go +++ b/internal/wsldocker/create.go @@ -47,15 +47,20 @@ type ContainerCreateSpec struct { WorkingDirectory string Mounts []ContainerMount TTY bool + // RetainUntilCleanup disables daemon auto-removal so an orchestrator can + // reliably collect even an ultra-short-lived process exit status before + // invoking the proof-bound cleanup operation. + RetainUntilCleanup bool } // Container is the immutable identity of one container created and // re-inspected through the proof-bound Docker Desktop WSL transport. type Container struct { - id string - runID string - namespace string - tool string + id string + runID string + namespace string + tool string + retainUntilCleanup bool } func (c Container) ID() string { return c.id } @@ -63,6 +68,14 @@ func (c Container) RunID() string { return c.runID } func (c Container) Namespace() string { return c.namespace } func (c Container) Tool() string { return c.tool } +// ValidateContainerCreateSpec performs the same complete request validation as +// CreateContainer without proving mounts or contacting Docker. Runtime +// orchestrators use it before ensuring named volumes so malformed execution +// input cannot leave otherwise-valid empty state behind. +func ValidateContainerCreateSpec(spec ContainerCreateSpec) error { + return validateContainerCreateSpec(spec) +} + type createDependencies struct { operations operationDependencies newRunID func() (string, error) @@ -108,7 +121,7 @@ func createContainer(ctx context.Context, spec ContainerCreateSpec, deps createD return Container{}, errors.New("generated Docker Desktop WSL container run identity is invalid") } - container := Container{runID: runID, namespace: spec.Namespace, tool: spec.Tool} + container := Container{runID: runID, namespace: spec.Namespace, tool: spec.Tool, retainUntilCleanup: spec.RetainUntilCleanup} body := containerCreateBody{ AttachStdin: true, AttachStdout: true, @@ -122,7 +135,7 @@ func createContainer(ctx context.Context, spec ContainerCreateSpec, deps createD Labels: containerLabels(container), WorkingDir: spec.WorkingDirectory, } - body.HostConfig.AutoRemove = true + body.HostConfig.AutoRemove = !spec.RetainUntilCleanup body.HostConfig.Mounts = append([]ContainerMount(nil), spec.Mounts...) raw, err := json.Marshal(body) if err != nil { @@ -169,9 +182,9 @@ func createContainer(ctx context.Context, spec ContainerCreateSpec, deps createD return Container{}, rollbackCreatedContainer(ctx, container, deps.operations, errors.New("created Docker container is already running")) } - if snapshot.TTY() != spec.TTY || !snapshot.AttachStdin() || !snapshot.AttachStdout() || !snapshot.AttachStderr() || !snapshot.OpenStdin() || !snapshot.StdinOnce() || !snapshot.AutoRemove() { + if snapshot.TTY() != spec.TTY || !snapshot.AttachStdin() || !snapshot.AttachStdout() || !snapshot.AttachStderr() || !snapshot.OpenStdin() || !snapshot.StdinOnce() || snapshot.AutoRemove() != !spec.RetainUntilCleanup { return Container{}, rollbackCreatedContainer(ctx, container, deps.operations, - errors.New("created Docker container stdio, terminal or auto-remove configuration does not match the request")) + errors.New("created Docker container stdio, terminal or retention configuration does not match the request")) } return container, nil } diff --git a/internal/wsldocker/create_test.go b/internal/wsldocker/create_test.go index e037cca..b2c4bab 100644 --- a/internal/wsldocker/create_test.go +++ b/internal/wsldocker/create_test.go @@ -69,6 +69,40 @@ func TestCreateContainerBuildsExactRequestAndReprovesOwnership(t *testing.T) { } } +func TestCreateContainerCanRetainOwnedContainerForExitStatusCollection(t *testing.T) { + spec := testContainerCreateSpec() + spec.Mounts = spec.Mounts[:1] + spec.RetainUntilCleanup = true + deps := testCreateDependencies() + calls := 0 + deps.operations.perform = func(_ context.Context, _ string, request Request) (operationResult, error) { + calls++ + switch calls { + case 1: + var body containerCreateBody + if err := json.Unmarshal(request.Body, &body); err != nil { + t.Fatal(err) + } + if body.HostConfig.AutoRemove { + t.Fatal("retained container unexpectedly enabled daemon auto-remove") + } + return operationResult{StatusCode: http.StatusCreated, PeerUID: 0, Raw: []byte(`{"Id":"` + testContainerID + `","Warnings":[]}`)}, nil + case 2: + return operationResult{StatusCode: http.StatusOK, PeerUID: 0, Raw: ownedContainerInspect(testContainerID, spec, testRunID, false, false)}, nil + default: + t.Fatalf("unexpected request %d: %#v", calls, request) + return operationResult{}, nil + } + } + container, err := createContainer(context.Background(), spec, deps) + if err != nil { + t.Fatal(err) + } + if !container.retainUntilCleanup || calls != 2 { + t.Fatalf("retained container = %#v, calls=%d", container, calls) + } +} + func TestCreateContainerRollsBackWarningAndPostCreateMismatch(t *testing.T) { for name, response := range map[string]func(ContainerCreateSpec) []byte{ "warning": func(ContainerCreateSpec) []byte { diff --git a/internal/wsldocker/remove.go b/internal/wsldocker/remove.go index 6ad3e1b..760685c 100644 --- a/internal/wsldocker/remove.go +++ b/internal/wsldocker/remove.go @@ -28,8 +28,8 @@ func removeContainer(ctx context.Context, container Container, deps operationDep if err := requireOwnedContainer(container, snapshot); err != nil { return fmt.Errorf("refuse Docker container removal: %w", err) } - if !snapshot.AutoRemove() { - return errors.New("refuse Docker container removal without auto-remove ownership configuration") + if snapshot.AutoRemove() != !container.retainUntilCleanup { + return errors.New("refuse Docker container removal whose retention configuration changed") } if snapshot.Running() { return errors.New("refuse non-force removal of a running Docker container") diff --git a/internal/wsldocker/remove_linux.go b/internal/wsldocker/remove_linux.go index cdd2128..e6fc7f7 100644 --- a/internal/wsldocker/remove_linux.go +++ b/internal/wsldocker/remove_linux.go @@ -5,7 +5,8 @@ package wsldocker import "context" // RemoveContainer removes one stopped container only after re-proving its exact -// ContainerBin WSL ownership labels. Already auto-removed containers succeed. +// ContainerBin WSL ownership labels and retention mode. Already auto-removed +// containers succeed. func RemoveContainer(ctx context.Context, container Container) error { return removeContainer(ctx, container, operationDependencies{ check: Check, diff --git a/internal/wsldocker/remove_test.go b/internal/wsldocker/remove_test.go index 583f18d..5eaca8e 100644 --- a/internal/wsldocker/remove_test.go +++ b/internal/wsldocker/remove_test.go @@ -37,6 +37,31 @@ func TestRemoveContainerProvesOwnershipAndVerifiesAbsence(t *testing.T) { } } +func TestRemoveContainerAcceptsExplicitlyRetainedOwnedContainer(t *testing.T) { + spec := testContainerCreateSpec() + spec.RetainUntilCleanup = true + container := Container{id: testContainerID, namespace: spec.Namespace, runID: testRunID, tool: spec.Tool, retainUntilCleanup: true} + deps := validOperationDependencies(validSocketInfo()) + calls := 0 + deps.perform = func(context.Context, string, Request) (operationResult, error) { + calls++ + switch calls { + case 1: + return operationResult{StatusCode: http.StatusOK, PeerUID: 0, Raw: ownedContainerInspect(container.id, spec, testRunID, false, false)}, nil + case 2: + return operationResult{StatusCode: http.StatusNoContent, PeerUID: 0}, nil + case 3: + return operationResult{StatusCode: http.StatusNotFound, PeerUID: 0, Raw: []byte(`{"message":"No such container"}`)}, nil + default: + t.Fatalf("unexpected request %d", calls) + return operationResult{}, nil + } + } + if err := removeContainer(context.Background(), container, deps); err != nil { + t.Fatal(err) + } +} + func TestRemoveContainerAcceptsAlreadyAutoRemovedContainer(t *testing.T) { container := Container{id: testContainerID, namespace: testWSLNamespace, runID: testRunID, tool: "node24"} deps := validOperationDependencies(validSocketInfo()) diff --git a/internal/wslfs/command.go b/internal/wslfs/command.go index 0f7a9cc..87e706c 100644 --- a/internal/wslfs/command.go +++ b/internal/wslfs/command.go @@ -28,9 +28,9 @@ func CurrentLayout() (hostenv.WSLLayout, error) { return currentLayout() } -// Run exposes only the fixed native-WSL filesystem preflight. It deliberately -// does not enable tool execution, install a binary, create shims or contact -// Docker; those remain separate qualification-gated slices. +// Run exposes only the fixed native-WSL filesystem preflight. This command +// does not execute tools, install a binary, create shims or contact Docker; +// installation and ordinary tool execution compose this preflight separately. func Run(args []string, out io.Writer) error { return (command{ currentLayout: currentLayout, diff --git a/internal/wslinstall/install.go b/internal/wslinstall/install.go index 1a5fa09..ccd3fba 100644 --- a/internal/wslinstall/install.go +++ b/internal/wslinstall/install.go @@ -1,6 +1,6 @@ // Package wslinstall composes the fixed native-WSL layout, machine policy, -// registry lifecycle, managed binary and symlink reconciliation. It does not -// enable ordinary tool execution; runtime wiring remains separately gated. +// registry lifecycle, managed binary and symlink reconciliation. It performs +// no Docker I/O; ordinary tool shims revalidate this identity at execution. package wslinstall import ( @@ -307,7 +307,7 @@ func printPlan(out io.Writer, plan Plan, applied bool) error { fmt.Fprintln(&report, "status: APPLY REQUIRED") fmt.Fprintln(&report, "apply: cb wsl install --apply") } - fmt.Fprintln(&report, "frontend: GATED (runtime wiring and WSL E2E are not enabled)") + fmt.Fprintln(&report, "frontend: RUNTIME ENABLED (release support requires real WSL qualification)") if _, err := io.WriteString(out, report.String()); err != nil { return fmt.Errorf("write native WSL installation report: %w", err) } diff --git a/internal/wslinstall/install_test.go b/internal/wslinstall/install_test.go index 6cf0998..f6942b7 100644 --- a/internal/wslinstall/install_test.go +++ b/internal/wslinstall/install_test.go @@ -54,7 +54,7 @@ func TestCheckIsReadOnlyAndReportsRequiredActions(t *testing.T) { if mutatingLoadCalled { t.Fatal("read-only check used mutating registry load") } - for _, want := range []string{"read-only; no files changed", "registry: create", "binary: create", "management: create", "APPLY REQUIRED", "frontend: GATED"} { + for _, want := range []string{"read-only; no files changed", "registry: create", "binary: create", "management: create", "APPLY REQUIRED", "frontend: RUNTIME ENABLED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } @@ -236,7 +236,7 @@ func TestApplyComposesRegistryBinaryAndShimLifecycle(t *testing.T) { if !prepared || !locked || !registryExists || !binaryReady || !managementReady || !shimsReady { t.Fatalf("incomplete lifecycle: prepared=%t locked=%t registry=%t binary=%t management=%t shims=%t", prepared, locked, registryExists, binaryReady, managementReady, shimsReady) } - for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: GATED"} { + for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: RUNTIME ENABLED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } diff --git a/internal/wslrun/frontend.go b/internal/wslrun/frontend.go new file mode 100644 index 0000000..e5386d7 --- /dev/null +++ b/internal/wslrun/frontend.go @@ -0,0 +1,114 @@ +package wslrun + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/wslfs" + "github.com/AviBackToBlack/container-bin/internal/wslshim" +) + +type frontendDependencies struct { + currentLayout func() (hostenv.WSLLayout, error) + checkLayout func(hostenv.WSLLayout) (wslfs.Plan, error) + checkRegistryRecovery func(hostenv.WSLLayout) error + loadPolicy func() (policy.Policy, error) + loadRegistry func(string, registry.Authenticator) (registry.Registry, string, error) + inspectShims func(hostenv.WSLLayout, []string) (wslshim.Result, error) + lstat func(string) (os.FileInfo, error) + executable func() (string, error) + evalSymlinks func(string) (string, error) + getwd func() (string, error) + interactive func() bool + environ func() []string + plan planDependencies + run runDependencies +} + +func runFrontend(ctx context.Context, invoked string, args []string, deps frontendDependencies) (int, error) { + if ctx == nil { + return 0, errors.New("native WSL frontend requires a context") + } + if !registry.ValidToolName(invoked) || registry.ReservedToolName(invoked) { + return 0, fmt.Errorf("invalid native WSL tool invocation name %q", invoked) + } + if deps.currentLayout == nil || deps.checkLayout == nil || deps.checkRegistryRecovery == nil || deps.loadPolicy == nil || + deps.loadRegistry == nil || deps.inspectShims == nil || deps.lstat == nil || deps.executable == nil || deps.evalSymlinks == nil || deps.getwd == nil || deps.interactive == nil || deps.environ == nil { + return 0, errors.New("native WSL frontend dependencies are incomplete") + } + layout, err := deps.currentLayout() + if err != nil { + return 0, fmt.Errorf("derive native WSL layout: %w", err) + } + checked, err := deps.checkLayout(layout) + if err != nil { + return 0, fmt.Errorf("validate native WSL layout: %w", err) + } + if checked.Layout != layout || len(checked.MissingDirectories) != 0 { + return 0, errors.New("native WSL installation layout is incomplete; run `cb wsl install --apply`") + } + if err := deps.checkRegistryRecovery(layout); err != nil { + return 0, fmt.Errorf("validate native WSL registry recovery state: %w", err) + } + if info, err := deps.lstat(layout.RegistryPath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return 0, errors.New("native WSL registry is not installed; run `cb wsl install --apply`") + } + return 0, fmt.Errorf("inspect native WSL registry: %w", err) + } else if !info.Mode().IsRegular() { + return 0, errors.New("native WSL registry is not a regular file") + } + shimResult, err := deps.inspectShims(layout, []string{invoked}) + if err != nil { + return 0, fmt.Errorf("validate native WSL managed tool shim: %w", err) + } + if len(shimResult.Shims) != 1 || shimResult.Shims[0].Name != invoked || shimResult.Shims[0].State != wslshim.Ready { + return 0, fmt.Errorf("native WSL tool shim %q is not installed; run `cb wsl install --apply`", invoked) + } + executable, err := deps.executable() + if err != nil { + return 0, fmt.Errorf("locate native WSL running executable: %w", err) + } + executable, err = filepath.Abs(executable) + if err != nil { + return 0, fmt.Errorf("canonicalize native WSL running executable: %w", err) + } + executable, err = deps.evalSymlinks(executable) + if err != nil { + return 0, fmt.Errorf("resolve native WSL running executable: %w", err) + } + if executable != layout.BinaryPath { + return 0, fmt.Errorf("native WSL tool execution requires managed binary %s, running executable is %s", layout.BinaryPath, executable) + } + machinePolicy, err := deps.loadPolicy() + if err != nil { + return 0, fmt.Errorf("load native WSL machine policy: %w", err) + } + reg, path, err := deps.loadRegistry(layout.RegistryPath, machinePolicy.AuthenticateRegistry) + if err != nil { + return 0, fmt.Errorf("load native WSL registry: %w", err) + } + if path != layout.RegistryPath { + return 0, fmt.Errorf("native WSL registry loader returned path %q, expected %q", path, layout.RegistryPath) + } + tool, _, ok := reg.Resolve(invoked) + if !ok { + return 0, fmt.Errorf("no tool profile for %q (registry: %s)", invoked, layout.RegistryPath) + } + cwd, err := deps.getwd() + if err != nil { + return 0, fmt.Errorf("determine native WSL working directory: %w", err) + } + plan, err := buildToolPlan(tool, args, machinePolicy, layout, cwd, deps.interactive(), deps.environ(), deps.plan) + if err != nil { + return 0, err + } + return executeTool(ctx, plan, deps.run) +} diff --git a/internal/wslrun/frontend_test.go b/internal/wslrun/frontend_test.go new file mode 100644 index 0000000..86d25ff --- /dev/null +++ b/internal/wslrun/frontend_test.go @@ -0,0 +1,104 @@ +package wslrun + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + "time" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/wslfs" + "github.com/AviBackToBlack/container-bin/internal/wslshim" +) + +func TestRunFrontendUsesOnlyFixedLayoutAndManagedIdentity(t *testing.T) { + layout := hostenv.WSLLayout{ + Distro: "Ubuntu-24.04", UID: 1000, Home: "/home/alice", + BinaryPath: "/home/alice/.local/lib/container-bin/cb", ManagementShim: "/home/alice/.local/bin/cb", + ShimDir: "/home/alice/.local/bin", ConfigDir: "/home/alice/.config/container-bin", + RegistryPath: "/home/alice/.config/container-bin/container-bin.toml", + LockPath: "/home/alice/.config/container-bin/container-bin.lock", + StateDir: "/home/alice/.local/state/container-bin", StateNamespace: testNamespace, + } + stream := &fakeAttach{reader: bytes.NewReader(rawFrame(1, "ok")), multiplexed: true, writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + runDeps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps := frontendDependencies{ + currentLayout: func() (hostenv.WSLLayout, error) { return layout, nil }, + checkLayout: func(got hostenv.WSLLayout) (wslfs.Plan, error) { return wslfs.Plan{Layout: got}, nil }, + checkRegistryRecovery: func(hostenv.WSLLayout) error { return nil }, + loadPolicy: func() (policy.Policy, error) { return policy.Policy{}, nil }, + loadRegistry: func(path string, _ registry.Authenticator) (registry.Registry, string, error) { + if path != layout.RegistryPath { + t.Fatalf("registry path = %q", path) + } + return registry.Registry{Tools: map[string]registry.Tool{ + "demo": {Name: "demo", Image: "demo:1", Provider: "stateless", Command: []string{"demo"}}, + }}, path, nil + }, + inspectShims: func(_ hostenv.WSLLayout, names []string) (wslshim.Result, error) { + return wslshim.Result{Shims: []wslshim.Shim{{Name: names[0], State: wslshim.Ready}}}, nil + }, + lstat: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o600}, nil }, + executable: func() (string, error) { return layout.BinaryPath, nil }, + evalSymlinks: func(value string) (string, error) { return value, nil }, + getwd: func() (string, error) { return "/project", nil }, + interactive: func() bool { return false }, + environ: func() []string { return nil }, + plan: testPlanDependencies(true), + run: runDeps, + } + code, err := runFrontend(context.Background(), "demo", []string{"input.txt"}, deps) + if err != nil { + t.Fatal(err) + } + if code != 23 || stdout.String() != "ok" || stderr.Len() != 0 { + t.Fatalf("frontend result code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } +} + +func TestRunFrontendRejectsUnmanagedExecutableBeforeConfigOrDocker(t *testing.T) { + layout := hostenv.WSLLayout{ + BinaryPath: "/home/alice/.local/lib/container-bin/cb", RegistryPath: "/home/alice/.config/container-bin/container-bin.toml", + } + deps := frontendDependencies{ + currentLayout: func() (hostenv.WSLLayout, error) { return layout, nil }, + checkLayout: func(got hostenv.WSLLayout) (wslfs.Plan, error) { return wslfs.Plan{Layout: got}, nil }, + checkRegistryRecovery: func(hostenv.WSLLayout) error { return nil }, + loadPolicy: func() (policy.Policy, error) { + t.Fatal("policy loaded for unmanaged binary") + return policy.Policy{}, nil + }, + loadRegistry: func(string, registry.Authenticator) (registry.Registry, string, error) { + t.Fatal("registry loaded for unmanaged binary") + return registry.Registry{}, "", nil + }, + inspectShims: func(hostenv.WSLLayout, []string) (wslshim.Result, error) { + return wslshim.Result{Shims: []wslshim.Shim{{Name: "demo", State: wslshim.Ready}}}, nil + }, + lstat: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o600}, nil }, + executable: func() (string, error) { return "/tmp/cb", nil }, + evalSymlinks: func(value string) (string, error) { return value, nil }, + getwd: func() (string, error) { return "/project", nil }, + interactive: func() bool { return false }, + environ: func() []string { return nil }, + } + _, err := runFrontend(context.Background(), "demo", nil, deps) + if err == nil || !strings.Contains(err.Error(), "requires managed binary") { + t.Fatalf("unmanaged executable error = %v", err) + } +} + +type fakeFileInfo struct{ mode os.FileMode } + +func (f fakeFileInfo) Name() string { return "file" } +func (f fakeFileInfo) Size() int64 { return 1 } +func (f fakeFileInfo) Mode() os.FileMode { return f.mode } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return f.mode.IsDir() } +func (f fakeFileInfo) Sys() any { return nil } diff --git a/internal/wslrun/plan.go b/internal/wslrun/plan.go new file mode 100644 index 0000000..65b2a4b --- /dev/null +++ b/internal/wslrun/plan.go @@ -0,0 +1,271 @@ +// Package wslrun composes the native WSL2 tool frontend from the proof-bound +// project, volume and Docker Engine primitives. It never consults Docker CLI +// configuration or translates Windows paths. +package wslrun + +import ( + "errors" + "fmt" + "path" + "sort" + "strings" + "unicode/utf8" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/lockfile" + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/wsldocker" + "github.com/AviBackToBlack/container-bin/internal/wslpathmap" + "github.com/AviBackToBlack/container-bin/internal/wslproject" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +const ( + projectWorkspace = "/workspace/project" + isolatedWorkspace = "/root" + pythonStateGroup = "python313" + pythonBootstrap = `if [ ! -x /venv/bin/python ]; then python -m venv /venv || exit $?; fi; if [ "$1" = "__CB_PIP__" ]; then shift; exec /venv/bin/python -m pip "$@"; else exec /venv/bin/python "$@"; fi` +) + +type toolPlan struct { + spec wsldocker.ContainerCreateSpec + volumes []wslvolume.Volume +} + +type planDependencies struct { + resolveImage func(registry.Tool, policy.Policy, string) (string, error) + selectProject func(string, registry.Tool) (wslproject.Project, bool, error) + classifyDescendant func(wslproject.Project, string) (wslproject.Descendant, error) + mapArgs func(registry.Tool, wslproject.Project, string, string, []string) ([]string, string, error) + planVolumes func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Binding, error) +} + +func buildToolPlan(tool registry.Tool, userArgs []string, machinePolicy policy.Policy, layout hostenv.WSLLayout, cwd string, tty bool, environ []string, deps planDependencies) (toolPlan, error) { + if deps.resolveImage == nil || deps.selectProject == nil || deps.classifyDescendant == nil || deps.mapArgs == nil || deps.planVolumes == nil { + return toolPlan{}, errors.New("native WSL runtime planning dependencies are incomplete") + } + if len(tool.HostMounts) != 0 { + return toolPlan{}, errors.New("native WSL profiles cannot use Windows host_mounts; use project paths or managed volumes") + } + scope, err := wslvolume.New(layout) + if err != nil { + return toolPlan{}, err + } + image, err := deps.resolveImage(tool, machinePolicy, layout.LockPath) + if err != nil { + return toolPlan{}, err + } + environment, err := selectedEnvironment(tool, environ) + if err != nil { + return toolPlan{}, err + } + + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: tool.Name, Namespace: scope.Namespace(), Image: image, + TTY: tty, Environment: environment, RetainUntilCleanup: true, + }} + var ( + project wslproject.Project + found bool + mapped []string + ) + if tool.CwdMode == "isolated" { + plan.spec.WorkingDirectory = isolatedWorkspace + mapped = append([]string(nil), userArgs...) + } else { + project, found, err = deps.selectProject(cwd, tool) + if err != nil { + return toolPlan{}, err + } + mapped, plan.spec.WorkingDirectory, err = deps.mapArgs(tool, project, cwd, projectWorkspace, userArgs) + if err != nil { + return toolPlan{}, err + } + plan.spec.Mounts = append(plan.spec.Mounts, wsldocker.ContainerMount{ + Type: "bind", Source: project.Root, Target: projectWorkspace, + }) + } + + appendVolume := func(volume wslvolume.Volume, destination string) error { + destination = path.Clean(destination) + for _, mount := range plan.spec.Mounts { + if mount.Target == destination { + return fmt.Errorf("native WSL mount target %s is declared more than once", destination) + } + } + plan.volumes = append(plan.volumes, volume) + plan.spec.Mounts = append(plan.spec.Mounts, wsldocker.ContainerMount{ + Type: "volume", Source: volume.Name(), Target: destination, VolumeLabels: volume.Labels(), + }) + return nil + } + + switch tool.Provider { + case "stateless": + plan.spec.Command = appendCommand(tool.Command, tool.ArgsPrefix, mapped) + case "stateful": + bindings, err := deps.planVolumes(scope, tool, project, projectWorkspace) + if err != nil { + return toolPlan{}, err + } + for _, binding := range bindings { + if err := appendVolume(binding.Volume(), binding.Destination()); err != nil { + return toolPlan{}, err + } + } + plan.spec.Command = appendCommand(tool.Command, tool.ArgsPrefix, mapped) + case "python": + if tool.CwdMode == "isolated" { + return toolPlan{}, errors.New("native WSL Python provider cannot use isolated cwd mode") + } + var venv wslvolume.Volume + if found { + root, proofErr := deps.classifyDescendant(project, project.Root) + if proofErr != nil { + return toolPlan{}, fmt.Errorf("prove native WSL Python project root: %w", proofErr) + } + if !root.Exists || root.Path != project.Root || root.Relative != "." || root.NearestExisting != project.Root { + return toolPlan{}, errors.New("native WSL Python project proof did not identify the exact existing root") + } + venv, err = scope.Project(pythonStateGroup, "venv", project.Root) + } else { + venv, err = scope.Shared(pythonStateGroup, "compat-venv") + } + if err != nil { + return toolPlan{}, err + } + pipCache, err := scope.Shared(pythonStateGroup, "pip-cache") + if err != nil { + return toolPlan{}, err + } + if err := appendVolume(venv, "/venv"); err != nil { + return toolPlan{}, err + } + if err := appendVolume(pipCache, "/root/.cache/pip"); err != nil { + return toolPlan{}, err + } + plan.spec.Environment, err = mergeLiteralEnvironment(plan.spec.Environment, + []string{"VIRTUAL_ENV=/venv", "PATH=/venv/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin"}) + if err != nil { + return toolPlan{}, err + } + plan.spec.Command = []string{"sh", "-c", pythonBootstrap, "cb"} + if tool.Role == "pip" { + plan.spec.Command = append(plan.spec.Command, "__CB_PIP__") + } + plan.spec.Command = append(plan.spec.Command, tool.ArgsPrefix...) + plan.spec.Command = append(plan.spec.Command, mapped...) + default: + return toolPlan{}, fmt.Errorf("unsupported native WSL provider %q", tool.Provider) + } + if err := wsldocker.ValidateContainerCreateSpec(plan.spec); err != nil { + return toolPlan{}, fmt.Errorf("validate native WSL container plan: %w", err) + } + return plan, nil +} + +func appendCommand(parts ...[]string) []string { + var result []string + for _, part := range parts { + result = append(result, part...) + } + return result +} + +func selectedEnvironment(tool registry.Tool, environ []string) ([]string, error) { + literal := make(map[string]string, len(tool.EnvSet)) + for _, assignment := range tool.EnvSet { + name, _, ok := strings.Cut(assignment, "=") + if !ok || !validEnvironmentName(name) || !utf8.ValidString(assignment) || strings.ContainsRune(assignment, '\x00') { + return nil, fmt.Errorf("invalid literal environment assignment %q", assignment) + } + if _, duplicate := literal[name]; duplicate { + return nil, fmt.Errorf("duplicate literal environment assignment for %s", name) + } + literal[name] = assignment + } + exact := make(map[string]bool, len(tool.EnvNames)) + for _, name := range tool.EnvNames { + exact[name] = true + } + selected := make(map[string]string) + for _, assignment := range environ { + name, _, ok := strings.Cut(assignment, "=") + if !ok || !validEnvironmentName(name) || !utf8.ValidString(assignment) || strings.ContainsRune(assignment, '\x00') { + continue + } + match := exact[name] + if !match { + for _, prefix := range tool.EnvPrefixes { + if strings.HasPrefix(name, prefix) { + match = true + break + } + } + } + if match { + if _, duplicate := selected[name]; duplicate { + return nil, fmt.Errorf("host environment contains duplicate selected variable %s", name) + } + selected[name] = assignment + } + } + for name, assignment := range literal { + selected[name] = assignment + } + names := make([]string, 0, len(selected)) + for name := range selected { + names = append(names, name) + } + sort.Strings(names) + result := make([]string, 0, len(names)) + for _, name := range names { + result = append(result, selected[name]) + } + return result, nil +} + +func mergeLiteralEnvironment(current, additions []string) ([]string, error) { + byName := make(map[string]string, len(current)+len(additions)) + for _, assignment := range append(append([]string(nil), current...), additions...) { + name, _, ok := strings.Cut(assignment, "=") + if !ok || !validEnvironmentName(name) { + return nil, fmt.Errorf("invalid environment assignment %q", assignment) + } + byName[name] = assignment + } + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) + } + sort.Strings(names) + result := make([]string, 0, len(names)) + for _, name := range names { + result = append(result, byName[name]) + } + return result, nil +} + +func validEnvironmentName(name string) bool { + if name == "" { + return false + } + for index, char := range name { + if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char == '_' || (index > 0 && char >= '0' && char <= '9') { + continue + } + return false + } + return true +} + +func productionPlanDependencies() planDependencies { + return planDependencies{ + resolveImage: lockfile.RuntimeImageForToolAt, + selectProject: wslproject.SelectForTool, + classifyDescendant: wslproject.ClassifyDescendant, + mapArgs: wslpathmap.MapToolArgs, + planVolumes: wslvolume.PlanStatefulToolVolumes, + } +} diff --git a/internal/wslrun/plan_test.go b/internal/wslrun/plan_test.go new file mode 100644 index 0000000..c8cc693 --- /dev/null +++ b/internal/wslrun/plan_test.go @@ -0,0 +1,153 @@ +package wslrun + +import ( + "reflect" + "strings" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/wslproject" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +const testNamespace = "wsl2-0123456789abcdef0123456789abcdef" + +func TestBuildToolPlanWiresProjectMappingEnvironmentAndCommand(t *testing.T) { + deps := testPlanDependencies(true) + tool := registry.Tool{ + Name: "demo", Image: "demo:1", Provider: "stateless", Command: []string{"demo"}, ArgsPrefix: []string{"--fixed"}, + EnvNames: []string{"KEEP"}, EnvPrefixes: []string{"APP_"}, EnvSet: []string{"APP_MODE=fixed"}, + } + plan, err := buildToolPlan(tool, []string{"input.txt"}, policy.Policy{}, testLayout(), "/project/sub", false, + []string{"DROP=no", "APP_MODE=host", "APP_TOKEN=secret", "KEEP=yes"}, deps) + if err != nil { + t.Fatal(err) + } + if plan.spec.Image != "demo@sha256:locked" || plan.spec.WorkingDirectory != "/workspace/project/sub" { + t.Fatalf("plan identity = image %q cwd %q", plan.spec.Image, plan.spec.WorkingDirectory) + } + if !plan.spec.RetainUntilCleanup { + t.Fatal("runtime plan did not retain the container for exit-status collection") + } + if got, want := plan.spec.Command, []string{"demo", "--fixed", "/workspace/project/input.txt"}; !reflect.DeepEqual(got, want) { + t.Fatalf("command = %#v, want %#v", got, want) + } + if got, want := plan.spec.Environment, []string{"APP_MODE=fixed", "APP_TOKEN=secret", "KEEP=yes"}; !reflect.DeepEqual(got, want) { + t.Fatalf("environment = %#v, want %#v", got, want) + } + if len(plan.spec.Mounts) != 1 || plan.spec.Mounts[0].Source != "/project" || plan.spec.Mounts[0].Target != projectWorkspace { + t.Fatalf("project mount = %#v", plan.spec.Mounts) + } + if len(plan.volumes) != 0 { + t.Fatalf("stateless plan has volumes %#v", plan.volumes) + } +} + +func TestBuildToolPlanWiresStatefulSharedVolumesInIsolatedMode(t *testing.T) { + deps := testPlanDependencies(false) + deps.planVolumes = wslvolume.PlanStatefulToolVolumes + tool := registry.Tool{ + Name: "demo", Image: "demo:1", Provider: "stateful", CwdMode: "isolated", + StateGroup: "demo", SharedVolumes: []string{"cache:/cb/cache"}, Command: []string{"demo"}, + } + plan, err := buildToolPlan(tool, []string{"arg"}, policy.Policy{}, testLayout(), "/ignored", false, nil, deps) + if err != nil { + t.Fatal(err) + } + if plan.spec.WorkingDirectory != isolatedWorkspace || len(plan.spec.Mounts) != 1 || plan.spec.Mounts[0].Type != "volume" { + t.Fatalf("isolated stateful plan = %+v", plan.spec) + } + if len(plan.volumes) != 1 || plan.spec.Mounts[0].Source != plan.volumes[0].Name() || plan.spec.Mounts[0].Target != "/cb/cache" { + t.Fatalf("isolated volume identity was not preserved: mounts=%#v volumes=%#v", plan.spec.Mounts, plan.volumes) + } +} + +func TestBuildToolPlanPreservesPythonProjectAndCompatibilityState(t *testing.T) { + for _, tc := range []struct { + name string + found bool + wantKind string + wantNamePart string + }{ + {name: "project", found: true, wantKind: "project", wantNamePart: "9-python313-4-venv-"}, + {name: "compatibility", found: false, wantKind: "shared", wantNamePart: "9-python313-11-compat-venv"}, + } { + t.Run(tc.name, func(t *testing.T) { + deps := testPlanDependencies(tc.found) + tool := registry.Tool{Name: "pip", Image: "python:3.13-slim", Provider: "python", Role: "pip"} + plan, err := buildToolPlan(tool, []string{"install", "demo"}, policy.Policy{}, testLayout(), "/project", false, nil, deps) + if err != nil { + t.Fatal(err) + } + if len(plan.volumes) != 2 || len(plan.spec.Mounts) != 3 { + t.Fatalf("Python plan volumes=%d mounts=%d", len(plan.volumes), len(plan.spec.Mounts)) + } + venv := plan.volumes[0] + if !strings.Contains(venv.Name(), tc.wantNamePart) || venv.Labels()["cb.kind"] != tc.wantKind { + t.Fatalf("venv identity = %q labels=%v", venv.Name(), venv.Labels()) + } + if got := strings.Join(plan.spec.Command, "|"); !strings.Contains(got, "__CB_PIP__|install|demo") { + t.Fatalf("pip command = %q", got) + } + if !containsAssignment(plan.spec.Environment, "VIRTUAL_ENV=/venv") || !containsAssignment(plan.spec.Environment, "PATH=/venv/bin:") { + t.Fatalf("Python environment = %#v", plan.spec.Environment) + } + }) + } +} + +func TestBuildToolPlanRejectsWindowsHostMountsBeforeResolution(t *testing.T) { + deps := testPlanDependencies(true) + deps.resolveImage = func(registry.Tool, policy.Policy, string) (string, error) { + t.Fatal("image resolution ran after unsupported host_mounts") + return "", nil + } + _, err := buildToolPlan(registry.Tool{ + Name: "demo", Image: "demo:1", Provider: "stateless", HostMounts: []string{`C:\\data:/data:ro`}, + }, nil, policy.Policy{}, testLayout(), "/project", false, nil, deps) + if err == nil || !strings.Contains(err.Error(), "cannot use Windows host_mounts") { + t.Fatalf("host_mounts error = %v", err) + } +} + +func testLayout() hostenv.WSLLayout { + return hostenv.WSLLayout{StateNamespace: testNamespace, LockPath: "/home/alice/.config/container-bin/container-bin.lock"} +} + +func testPlanDependencies(found bool) planDependencies { + project := wslproject.Project{Root: "/project", Storage: wslproject.Distribution, MountPoint: "/"} + return planDependencies{ + resolveImage: func(registry.Tool, policy.Policy, string) (string, error) { return "demo@sha256:locked", nil }, + selectProject: func(string, registry.Tool) (wslproject.Project, bool, error) { return project, found, nil }, + classifyDescendant: func(_ wslproject.Project, candidate string) (wslproject.Descendant, error) { + relative := "." + if candidate != "/project" { + relative = strings.TrimPrefix(candidate, "/project/") + } + return wslproject.Descendant{Path: candidate, Relative: relative, Exists: true, NearestExisting: candidate}, nil + }, + mapArgs: func(_ registry.Tool, _ wslproject.Project, _ string, workspace string, args []string) ([]string, string, error) { + mapped := append([]string(nil), args...) + for index, argument := range mapped { + if argument == "input.txt" { + mapped[index] = workspace + "/input.txt" + } + } + return mapped, workspace + "/sub", nil + }, + planVolumes: func(wslvolume.Scope, registry.Tool, wslproject.Project, string) ([]wslvolume.Binding, error) { + return nil, nil + }, + } +} + +func containsAssignment(assignments []string, prefix string) bool { + for _, assignment := range assignments { + if strings.HasPrefix(assignment, prefix) { + return true + } + } + return false +} diff --git a/internal/wslrun/run_linux.go b/internal/wslrun/run_linux.go new file mode 100644 index 0000000..0b9751f --- /dev/null +++ b/internal/wslrun/run_linux.go @@ -0,0 +1,68 @@ +//go:build linux + +package wslrun + +import ( + "context" + "errors" + "os" + "path/filepath" + + "github.com/AviBackToBlack/container-bin/internal/policy" + "github.com/AviBackToBlack/container-bin/internal/registry" + "github.com/AviBackToBlack/container-bin/internal/terminal" + "github.com/AviBackToBlack/container-bin/internal/wsldocker" + "github.com/AviBackToBlack/container-bin/internal/wslfs" + "github.com/AviBackToBlack/container-bin/internal/wslshim" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +// Run executes one registry-derived native WSL tool shim through the fixed +// layout and proof-bound Docker Desktop Engine transport. +func Run(ctx context.Context, invoked string, args []string) (int, error) { + return runFrontend(ctx, invoked, args, productionFrontendDependencies()) +} + +func productionFrontendDependencies() frontendDependencies { + return frontendDependencies{ + currentLayout: wslfs.CurrentLayout, + checkLayout: wslfs.Check, + checkRegistryRecovery: wslfs.CheckRegistryRecovery, + loadPolicy: policy.Load, + loadRegistry: registry.LoadAt, + inspectShims: wslshim.Inspect, + lstat: os.Lstat, + executable: os.Executable, + evalSymlinks: filepath.EvalSymlinks, + getwd: os.Getwd, + interactive: terminal.Interactive, + environ: os.Environ, + plan: productionPlanDependencies(), + run: runDependencies{ + ensureVolume: wslvolume.Ensure, + create: func(ctx context.Context, spec wsldocker.ContainerCreateSpec) (containerHandle, error) { + container, err := wsldocker.CreateContainer(ctx, spec) + return containerHandle{id: container.ID(), native: container}, err + }, + attach: func(ctx context.Context, request wsldocker.AttachRequest) (attachStream, error) { + return wsldocker.OpenAttach(ctx, request) + }, + start: wsldocker.StartContainer, + wait: wsldocker.WaitContainer, + resize: wsldocker.ResizeContainer, + signal: wsldocker.SignalContainer, + remove: func(ctx context.Context, handle containerHandle) error { + container, ok := handle.native.(wsldocker.Container) + if !ok || container.ID() != handle.id { + return errors.New("native WSL container cleanup identity is invalid") + } + return wsldocker.RemoveContainer(ctx, container) + }, + prepareTerminal: prepareHostTerminal, + startEvents: startHostEvents, + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + }, + } +} diff --git a/internal/wslrun/run_other.go b/internal/wslrun/run_other.go new file mode 100644 index 0000000..892942f --- /dev/null +++ b/internal/wslrun/run_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package wslrun + +import ( + "context" + "errors" +) + +func Run(context.Context, string, []string) (int, error) { + return 0, errors.New("native WSL tool execution requires Linux") +} diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go new file mode 100644 index 0000000..a0c0ff0 --- /dev/null +++ b/internal/wslrun/runner.go @@ -0,0 +1,255 @@ +package wslrun + +import ( + "context" + "errors" + "fmt" + "io" + "time" + + "github.com/AviBackToBlack/container-bin/internal/wsldocker" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +const ( + cleanupTimeout = 30 * time.Second + outputDrainTimeout = 5 * time.Second +) + +type attachStream interface { + io.ReadWriteCloser + CloseWrite() error + Multiplexed() bool +} + +type containerHandle struct { + id string + native any +} + +type hostEvent struct { + signal int + resize bool + height uint16 + width uint16 + err error +} + +type terminalControl struct { + height uint16 + width uint16 + restore func() error +} + +type waitResult struct { + code int + err error +} + +type runDependencies struct { + ensureVolume func(context.Context, wslvolume.Volume) error + create func(context.Context, wsldocker.ContainerCreateSpec) (containerHandle, error) + attach func(context.Context, wsldocker.AttachRequest) (attachStream, error) + start func(context.Context, string) error + wait func(context.Context, string) (int, error) + resize func(context.Context, string, uint16, uint16) error + signal func(context.Context, string, int) error + remove func(context.Context, containerHandle) error + prepareTerminal func(bool) (terminalControl, error) + startEvents func(bool) (<-chan hostEvent, func(), error) + stdin io.Reader + stdout io.Writer + stderr io.Writer +} + +func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code int, retErr error) { + if ctx == nil { + return 0, errors.New("native WSL tool execution requires a context") + } + if deps.ensureVolume == nil || deps.create == nil || deps.attach == nil || deps.start == nil || deps.wait == nil || + deps.resize == nil || deps.signal == nil || deps.remove == nil || deps.prepareTerminal == nil || deps.startEvents == nil || + deps.stdin == nil || deps.stdout == nil || deps.stderr == nil { + return 0, errors.New("native WSL tool execution dependencies are incomplete") + } + runCtx, cancelRun := context.WithCancel(ctx) + defer cancelRun() + for _, volume := range plan.volumes { + if err := deps.ensureVolume(runCtx, volume); err != nil { + return 0, fmt.Errorf("ensure native WSL volume %s: %w", volume.Name(), err) + } + } + container, err := deps.create(runCtx, plan.spec) + if err != nil { + return 0, fmt.Errorf("create native WSL tool container: %w", err) + } + if container.id == "" { + return 0, errors.New("native WSL container creation returned an empty identity") + } + + var ( + stream attachStream + running bool + term terminalControl + stopEvents func() + ) + defer func() { + cancelRun() + if stopEvents != nil { + stopEvents() + } + if stream != nil { + if err := stream.Close(); retErr == nil && err != nil { + retErr = fmt.Errorf("close native WSL attach stream: %w", err) + } + } + if term.restore != nil { + if err := term.restore(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("restore native WSL terminal: %w", err)) + } + } + cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + var lifecycleErr error + if running { + if err := deps.signal(cleanupCtx, container.id, 9); err != nil { + lifecycleErr = fmt.Errorf("stop native WSL tool container after failure: %w", err) + } else if _, err := deps.wait(cleanupCtx, container.id); err != nil { + lifecycleErr = fmt.Errorf("wait for native WSL tool container after forced stop: %w", err) + } else { + running = false + } + } + removed := false + if running { + // Start and wait failures can be transport-ambiguous. A proof-bound + // non-force removal safely distinguishes an already-stopped container + // from one that is still running without guessing or force-deleting it. + if err := deps.remove(cleanupCtx, container); err != nil { + retErr = errors.Join(retErr, lifecycleErr, fmt.Errorf("clean up ambiguously running native WSL tool container: %w", err)) + } else { + removed = true + running = false + } + } + if !running && !removed { + if err := deps.remove(cleanupCtx, container); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("clean up native WSL tool container: %w", err)) + } + } + }() + + events, stop, err := deps.startEvents(plan.spec.TTY) + if err != nil { + return 0, err + } + stopEvents = stop + stream, err = deps.attach(runCtx, wsldocker.AttachRequest{ + ContainerID: container.id, Stdin: true, Stdout: true, Stderr: true, TTY: plan.spec.TTY, + }) + if err != nil { + return 0, fmt.Errorf("attach native WSL tool container: %w", err) + } + term, err = deps.prepareTerminal(plan.spec.TTY) + if err != nil { + return 0, err + } + // Once start is submitted, its transport result cannot prove whether the + // Engine acted. Treat the container as possibly running until a wait or the + // proof-bound cleanup path establishes otherwise. + running = true + if err := deps.start(runCtx, container.id); err != nil { + return 0, fmt.Errorf("start native WSL tool container: %w", err) + } + if plan.spec.TTY { + if err := deps.resize(runCtx, container.id, term.height, term.width); err != nil { + return 0, fmt.Errorf("set initial native WSL container terminal size: %w", err) + } + } + + outputDone := make(chan error, 1) + go func() { + if stream.Multiplexed() { + _, err := wsldocker.CopyMultiplexedOutput(deps.stdout, deps.stderr, stream) + outputDone <- err + return + } + _, err := io.Copy(deps.stdout, stream) + outputDone <- err + }() + inputDone := make(chan error, 1) + go func() { + _, copyErr := io.Copy(stream, deps.stdin) + closeErr := stream.CloseWrite() + if copyErr != nil { + inputDone <- copyErr + return + } + inputDone <- closeErr + }() + waitDone := make(chan waitResult, 1) + go func() { + waitCode, waitErr := deps.wait(runCtx, container.id) + waitDone <- waitResult{code: waitCode, err: waitErr} + }() + + var outputResult *error + for { + select { + case result := <-waitDone: + if result.err != nil { + return 0, fmt.Errorf("wait for native WSL tool container: %w", result.err) + } + running = false + if outputResult != nil { + if *outputResult != nil { + return 0, fmt.Errorf("copy native WSL tool output: %w", *outputResult) + } + return result.code, nil + } + timer := time.NewTimer(outputDrainTimeout) + select { + case outputErr := <-outputDone: + timer.Stop() + if outputErr != nil { + return 0, fmt.Errorf("copy native WSL tool output: %w", outputErr) + } + return result.code, nil + case <-timer.C: + return 0, errors.New("native WSL attach stream did not close after the container exited") + case <-ctx.Done(): + timer.Stop() + return 0, fmt.Errorf("native WSL tool execution canceled while draining output: %w", ctx.Err()) + } + case outputErr := <-outputDone: + outputResult = &outputErr + outputDone = nil + if outputErr != nil { + return 0, fmt.Errorf("copy native WSL tool output: %w", outputErr) + } + case inputErr := <-inputDone: + inputDone = nil + if inputErr != nil && !errors.Is(inputErr, io.ErrClosedPipe) { + return 0, fmt.Errorf("copy native WSL tool input: %w", inputErr) + } + case event, ok := <-events: + if !ok { + events = nil + continue + } + if event.err != nil { + return 0, event.err + } + if event.resize { + if err := deps.resize(runCtx, container.id, event.height, event.width); err != nil { + return 0, fmt.Errorf("resize native WSL tool terminal: %w", err) + } + } else if event.signal != 0 { + if err := deps.signal(runCtx, container.id, event.signal); err != nil { + return 0, fmt.Errorf("forward signal %d to native WSL tool container: %w", event.signal, err) + } + } + case <-ctx.Done(): + return 0, fmt.Errorf("native WSL tool execution canceled: %w", ctx.Err()) + } + } +} diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go new file mode 100644 index 0000000..537d95c --- /dev/null +++ b/internal/wslrun/runner_test.go @@ -0,0 +1,257 @@ +package wslrun + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "reflect" + "strings" + "sync" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/hostenv" + "github.com/AviBackToBlack/container-bin/internal/wsldocker" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +type fakeAttach struct { + mu sync.Mutex + reader *bytes.Reader + writes bytes.Buffer + multiplexed bool + closedWrite bool + closed bool + writeDone chan struct{} + writeOnce sync.Once +} + +func (s *fakeAttach) Read(p []byte) (int, error) { return s.reader.Read(p) } +func (s *fakeAttach) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.writes.Write(p) +} +func (s *fakeAttach) CloseWrite() error { + s.closedWrite = true + s.writeOnce.Do(func() { + if s.writeDone != nil { + close(s.writeDone) + } + }) + return nil +} +func (s *fakeAttach) Close() error { s.closed = true; return nil } +func (s *fakeAttach) Multiplexed() bool { return s.multiplexed } + +func TestExecuteToolStreamsMultiplexedIOAndPropagatesExitCode(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader(append(rawFrame(1, "out"), rawFrame(2, "err")...)), multiplexed: true, writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + scope, err := wslvolume.New(hostenv.WSLLayout{StateNamespace: testNamespace}) + if err != nil { + t.Fatal(err) + } + volume, err := scope.Shared("demo", "cache") + if err != nil { + t.Fatal(err) + } + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + plan := toolPlan{ + spec: wsldocker.ContainerCreateSpec{Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root"}, + volumes: []wslvolume.Volume{volume}, + } + code, err := executeTool(context.Background(), plan, deps) + if err != nil { + t.Fatal(err) + } + if code != 23 || stdout.String() != "out" || stderr.String() != "err" { + t.Fatalf("result code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if !stream.closedWrite || !stream.closed || stream.writes.String() != "input" { + t.Fatalf("stream state write=%q closeWrite=%t close=%t", stream.writes.String(), stream.closedWrite, stream.closed) + } + wantCalls := []string{"ensure:" + volume.Name(), "create", "events", "attach", "start", "wait", "remove"} + if !reflect.DeepEqual(calls, wantCalls) { + t.Fatalf("calls = %#v, want %#v", calls, wantCalls) + } +} + +func TestExecuteToolAppliesTTYSizeAndForwardsHostEvents(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader([]byte("tty output")), writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + events := make(chan hostEvent, 2) + events <- hostEvent{signal: 2} + events <- hostEvent{resize: true, height: 40, width: 120} + close(events) + deps.prepareTerminal = func(tty bool) (terminalControl, error) { + if !tty { + t.Fatal("TTY plan was prepared as non-interactive") + } + return terminalControl{height: 24, width: 80, restore: func() error { return nil }}, nil + } + deps.startEvents = func(tty bool) (<-chan hostEvent, func(), error) { + if !tty { + t.Fatal("TTY events were started as non-interactive") + } + return events, func() {}, nil + } + var sizes [][2]uint16 + waitGate := make(chan struct{}) + deps.resize = func(_ context.Context, _ string, height, width uint16) error { + sizes = append(sizes, [2]uint16{height, width}) + if len(sizes) == 2 { + close(waitGate) + } + return nil + } + var signals []int + deps.signal = func(_ context.Context, _ string, signal int) error { + signals = append(signals, signal) + return nil + } + deps.wait = func(context.Context, string) (int, error) { + calls = append(calls, "wait") + <-waitGate + return 130, nil + } + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", TTY: true, + }} + code, err := executeTool(context.Background(), plan, deps) + if err != nil { + t.Fatal(err) + } + if code != 130 || stdout.String() != "tty output" || stderr.Len() != 0 { + t.Fatalf("TTY result code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if got, want := sizes, [][2]uint16{{24, 80}, {40, 120}}; !reflect.DeepEqual(got, want) { + t.Fatalf("terminal sizes = %#v, want %#v", got, want) + } + if !reflect.DeepEqual(signals, []int{2}) { + t.Fatalf("forwarded signals = %#v", signals) + } +} + +func TestExecuteToolForceStopsOwnedContainerAfterStreamFailure(t *testing.T) { + malformed := rawFrame(1, "bad") + malformed[1] = 1 + stream := &fakeAttach{reader: bytes.NewReader(malformed), multiplexed: true, writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps.wait = func(ctx context.Context, _ string) (int, error) { + if _, cleanup := ctx.Deadline(); cleanup { + calls = append(calls, "cleanup-wait") + return 137, nil + } + <-ctx.Done() + return 0, ctx.Err() + } + var signals []int + deps.signal = func(_ context.Context, _ string, signal int) error { + signals = append(signals, signal) + return nil + } + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", RetainUntilCleanup: true, + }} + _, err := executeTool(context.Background(), plan, deps) + if err == nil || !strings.Contains(err.Error(), "raw-stream frame") { + t.Fatalf("stream failure = %v", err) + } + if !reflect.DeepEqual(signals, []int{9}) { + t.Fatalf("cleanup signals = %#v", signals) + } + if !containsCall(calls, "cleanup-wait") || !containsCall(calls, "remove") { + t.Fatalf("cleanup calls = %#v", calls) + } +} + +func TestExecuteToolCleansUpAfterAmbiguousStartFailure(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader(nil), writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps.start = func(context.Context, string) error { + calls = append(calls, "start") + return errors.New("connection closed") + } + deps.signal = func(context.Context, string, int) error { + calls = append(calls, "signal") + return errors.New("container is not running") + } + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", RetainUntilCleanup: true, + }} + _, err := executeTool(context.Background(), plan, deps) + if err == nil || !strings.Contains(err.Error(), "start native WSL tool container: connection closed") { + t.Fatalf("start failure = %v", err) + } + if got, want := calls, []string{"create", "events", "attach", "start", "signal", "remove"}; !reflect.DeepEqual(got, want) { + t.Fatalf("cleanup calls = %#v, want %#v", got, want) + } +} + +func successfulRunDependencies(t *testing.T, stream *fakeAttach, stdout, stderr io.Writer, calls *[]string) runDependencies { + t.Helper() + return runDependencies{ + ensureVolume: func(_ context.Context, volume wslvolume.Volume) error { + *calls = append(*calls, "ensure:"+volume.Name()) + return nil + }, + create: func(context.Context, wsldocker.ContainerCreateSpec) (containerHandle, error) { + *calls = append(*calls, "create") + return containerHandle{id: strings.Repeat("a", 64), native: "owned"}, nil + }, + attach: func(context.Context, wsldocker.AttachRequest) (attachStream, error) { + *calls = append(*calls, "attach") + return stream, nil + }, + start: func(context.Context, string) error { *calls = append(*calls, "start"); return nil }, + wait: func(context.Context, string) (int, error) { + *calls = append(*calls, "wait") + <-stream.writeDone + return 23, nil + }, + resize: func(context.Context, string, uint16, uint16) error { return nil }, + signal: func(context.Context, string, int) error { return nil }, + remove: func(_ context.Context, handle containerHandle) error { + *calls = append(*calls, "remove") + if handle.native != "owned" { + t.Fatalf("cleanup handle = %#v", handle) + } + return nil + }, + prepareTerminal: func(bool) (terminalControl, error) { return terminalControl{restore: func() error { return nil }}, nil }, + startEvents: func(bool) (<-chan hostEvent, func(), error) { + *calls = append(*calls, "events") + events := make(chan hostEvent) + close(events) + return events, func() {}, nil + }, + stdin: strings.NewReader("input"), + stdout: stdout, + stderr: stderr, + } +} + +func rawFrame(stream byte, payload string) []byte { + frame := make([]byte, 8+len(payload)) + frame[0] = stream + binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload))) + copy(frame[8:], payload) + return frame +} + +func containsCall(calls []string, want string) bool { + for _, call := range calls { + if call == want { + return true + } + } + return false +} diff --git a/internal/wslrun/terminal_linux.go b/internal/wslrun/terminal_linux.go new file mode 100644 index 0000000..517945f --- /dev/null +++ b/internal/wslrun/terminal_linux.go @@ -0,0 +1,141 @@ +//go:build linux + +package wslrun + +import ( + "errors" + "fmt" + "os" + ossignal "os/signal" + "sync" + "syscall" + "unsafe" +) + +func prepareHostTerminal(tty bool) (terminalControl, error) { + if !tty { + return terminalControl{restore: func() error { return nil }}, nil + } + stdinFD := os.Stdin.Fd() + original, err := getTermios(stdinFD) + if err != nil { + return terminalControl{}, fmt.Errorf("inspect native WSL terminal mode: %w", err) + } + raw := original + raw.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON + raw.Oflag &^= syscall.OPOST + raw.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN + raw.Cflag &^= syscall.CSIZE | syscall.PARENB + raw.Cflag |= syscall.CS8 + raw.Cc[syscall.VMIN] = 1 + raw.Cc[syscall.VTIME] = 0 + if err := setTermios(stdinFD, raw); err != nil { + return terminalControl{}, fmt.Errorf("enter native WSL raw terminal mode: %w", err) + } + height, width, err := terminalSize(os.Stdout.Fd()) + if err != nil { + _ = setTermios(stdinFD, original) + return terminalControl{}, fmt.Errorf("read native WSL terminal size: %w", err) + } + var ( + once sync.Once + restoreErr error + ) + return terminalControl{ + height: height, + width: width, + restore: func() error { + once.Do(func() { restoreErr = setTermios(stdinFD, original) }) + return restoreErr + }, + }, nil +} + +func startHostEvents(tty bool) (<-chan hostEvent, func(), error) { + signals := make(chan os.Signal, 16) + watched := []os.Signal{ + syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGUSR1, + syscall.SIGUSR2, syscall.SIGTERM, syscall.SIGCONT, syscall.SIGTSTP, + } + if tty { + watched = append(watched, syscall.SIGWINCH) + } + ossignal.Notify(signals, watched...) + events := make(chan hostEvent, 16) + done := make(chan struct{}) + var once sync.Once + stop := func() { + once.Do(func() { + ossignal.Stop(signals) + close(done) + }) + } + go func() { + defer close(events) + for { + select { + case <-done: + return + case received := <-signals: + number, ok := received.(syscall.Signal) + if !ok { + select { + case events <- hostEvent{err: errors.New("native WSL received a signal without a Linux number")}: + case <-done: + } + continue + } + event := hostEvent{signal: int(number)} + if number == syscall.SIGWINCH { + height, width, err := terminalSize(os.Stdout.Fd()) + event = hostEvent{resize: true, height: height, width: width} + if err != nil { + event = hostEvent{err: fmt.Errorf("read resized native WSL terminal: %w", err)} + } + } + select { + case events <- event: + case <-done: + return + } + } + } + }() + return events, stop, nil +} + +func getTermios(fd uintptr) (syscall.Termios, error) { + var value syscall.Termios + _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, syscall.TCGETS, uintptr(unsafe.Pointer(&value)), 0, 0, 0) + if errno != 0 { + return syscall.Termios{}, errno + } + return value, nil +} + +func setTermios(fd uintptr, value syscall.Termios) error { + _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, syscall.TCSETS, uintptr(unsafe.Pointer(&value)), 0, 0, 0) + if errno != 0 { + return errno + } + return nil +} + +func terminalSize(fd uintptr) (uint16, uint16, error) { + // Linux struct winsize is four consecutive unsigned shorts. Keep the + // definition local rather than adding x/sys solely for one ioctl. + var size struct { + Row uint16 + Col uint16 + Xpixel uint16 + Ypixel uint16 + } + _, _, errno := syscall.Syscall6(syscall.SYS_IOCTL, fd, syscall.TIOCGWINSZ, uintptr(unsafe.Pointer(&size)), 0, 0, 0) + if errno != 0 { + return 0, 0, errno + } + if size.Row == 0 || size.Col == 0 { + return 0, 0, errors.New("terminal reported zero rows or columns") + } + return size.Row, size.Col, nil +} diff --git a/main.go b/main.go index f0721df..62062a2 100644 --- a/main.go +++ b/main.go @@ -22,6 +22,7 @@ import ( "github.com/AviBackToBlack/container-bin/internal/state" "github.com/AviBackToBlack/container-bin/internal/wslfs" "github.com/AviBackToBlack/container-bin/internal/wslinstall" + "github.com/AviBackToBlack/container-bin/internal/wslrun" ) // version is injected at release time via: @@ -40,6 +41,11 @@ var loadPolicy = policy.Load // requireHostFrontend is a test seam around the fail-closed host boundary. // Production always uses hostenv.RequireFrontend. var requireHostFrontend = hostenv.RequireFrontend +var currentHostRuntime = hostenv.Current + +// runWSLTool is the ordinary native-WSL tool-dispatch seam. The implementation +// owns fixed-layout, registry, project, Docker and process validation. +var runWSLTool = wslrun.Run // runSelfUpdate is a test seam for proving the complete explicit self-update // command remains available before policy or registry I/O. Production always @@ -73,6 +79,24 @@ func main() { fatalf("host runtime: %v", err) return } + hostRuntime, err := currentHostRuntime() + if err != nil { + fatalf("host runtime: %v", err) + return + } + if hostRuntime.Kind == hostenv.WSL2Native { + if isManagementInvocation(invoked) { + fatalf("native WSL management command %q is unavailable; use `cb wsl install --check|--apply`, `cb version`, `cb help`, or a managed tool shim", strings.Join(os.Args[1:], " ")) + return + } + code, err := runWSLTool(context.Background(), invoked, os.Args[1:]) + if err != nil { + fatalf("%v", err) + return + } + osExit(code) + return + } if selfupdate.IsHelperInvocation(invoked, os.Args[1:]) { if err := runSelfUpdateHelper(context.Background(), os.Args[2:], os.Stdout); err != nil { fatalf("self-update helper: %v", err) @@ -417,7 +441,7 @@ func bootstrapRegistryPath() string { } func usage(cfg string) { - fmt.Printf(`container-bin (cb) %s — Docker-backed Windows CLI shims + fmt.Printf(`container-bin (cb) %s — Docker-backed Windows and native WSL2 CLI shims Commands: cb setup initialize/upgrade registry, install shims, then run doctor @@ -454,6 +478,10 @@ Commands: cb version print container-bin version cb help print this help without loading the registry +Native WSL2: + Bootstrap, cb wsl ..., and managed tool shims are enabled. Other management + commands remain Windows-only until their native state/update contracts land. + Registry: %s `, version, cfg) diff --git a/main_test.go b/main_test.go index e923c2e..27f150d 100644 --- a/main_test.go +++ b/main_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/policy" "github.com/AviBackToBlack/container-bin/internal/projectconfig" "github.com/AviBackToBlack/container-bin/internal/registry" @@ -148,6 +149,57 @@ func TestWSLPreflightSkipsGeneralHostPolicyAndRegistry(t *testing.T) { } } +func TestNativeWSLToolDispatchSkipsWindowsRegistryAndRuntime(t *testing.T) { + oldArgs := os.Args + oldRequireHostFrontend := requireHostFrontend + oldCurrentHostRuntime := currentHostRuntime + oldRunWSLTool := runWSLTool + oldLoadRegistry := loadRegistry + oldLoadPolicy := loadPolicy + oldExit := osExit + defer func() { + os.Args = oldArgs + requireHostFrontend = oldRequireHostFrontend + currentHostRuntime = oldCurrentHostRuntime + runWSLTool = oldRunWSLTool + loadRegistry = oldLoadRegistry + loadPolicy = oldLoadPolicy + osExit = oldExit + }() + + requireHostFrontend = func() error { return nil } + currentHostRuntime = func() (hostenv.Runtime, error) { + return hostenv.Runtime{Kind: hostenv.WSL2Native, Distro: "Ubuntu-24.04"}, nil + } + loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { + panic("native WSL dispatch attempted the Windows registry loader") + } + loadPolicy = func() (policy.Policy, error) { + panic("native WSL dispatch attempted the Windows runtime path") + } + called := false + runWSLTool = func(_ context.Context, invoked string, args []string) (int, error) { + called = true + if invoked != "node" || strings.Join(args, " ") != "--version" { + t.Fatalf("WSL dispatch = %q %#v", invoked, args) + } + return 42, nil + } + type exitCode int + osExit = func(code int) { panic(exitCode(code)) } + os.Args = []string{"node", "--version"} + + defer func() { + if got := recover(); got != exitCode(42) { + t.Fatalf("main panic = %v, want tool exit 42", got) + } + if !called { + t.Fatal("native WSL tool runner was not called") + } + }() + main() +} + func TestHostBoundaryPrecedesPolicyAndRegistryLoad(t *testing.T) { oldArgs := os.Args oldLoadRegistry := loadRegistry @@ -195,12 +247,14 @@ func TestSelfUpdateEnforcesHostBoundaryAndSkipsPolicyAndRegistry(t *testing.T) { oldLoadPolicy := loadPolicy oldRequireHostFrontend := requireHostFrontend oldRunSelfUpdate := runSelfUpdate + oldCurrentHostRuntime := currentHostRuntime defer func() { os.Args = oldArgs loadRegistry = oldLoadRegistry loadPolicy = oldLoadPolicy requireHostFrontend = oldRequireHostFrontend runSelfUpdate = oldRunSelfUpdate + currentHostRuntime = oldCurrentHostRuntime }() hostChecked := false @@ -208,6 +262,7 @@ func TestSelfUpdateEnforcesHostBoundaryAndSkipsPolicyAndRegistry(t *testing.T) { hostChecked = true return nil } + currentHostRuntime = func() (hostenv.Runtime, error) { return hostenv.Runtime{Kind: hostenv.WindowsNative}, nil } loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { panic("self-update check attempted to load the registry") } @@ -241,16 +296,19 @@ func TestSelfUpdateHelperDispatchIsPrivateAndSkipsPolicyAndRegistry(t *testing.T oldLoadPolicy := loadPolicy oldRequireHostFrontend := requireHostFrontend oldRunSelfUpdateHelper := runSelfUpdateHelper + oldCurrentHostRuntime := currentHostRuntime defer func() { os.Args = oldArgs loadRegistry = oldLoadRegistry loadPolicy = oldLoadPolicy requireHostFrontend = oldRequireHostFrontend runSelfUpdateHelper = oldRunSelfUpdateHelper + currentHostRuntime = oldCurrentHostRuntime }() hostChecked := false requireHostFrontend = func() error { hostChecked = true; return nil } + currentHostRuntime = func() (hostenv.Runtime, error) { return hostenv.Runtime{Kind: hostenv.WindowsNative}, nil } loadRegistry = func(registry.Authenticator) (registry.Registry, string, error) { panic("self-update helper attempted to load the registry") } From b44e96c80cc31136ba6a8e52e1beb9ffc51bb25a Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:22:50 +0100 Subject: [PATCH 2/8] Keep WSL frontend tests host neutral --- internal/wslrun/frontend.go | 6 +++--- internal/wslrun/frontend_test.go | 2 ++ internal/wslrun/run_linux.go | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/wslrun/frontend.go b/internal/wslrun/frontend.go index e5386d7..e34d3e8 100644 --- a/internal/wslrun/frontend.go +++ b/internal/wslrun/frontend.go @@ -6,7 +6,6 @@ import ( "fmt" "io/fs" "os" - "path/filepath" "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/policy" @@ -24,6 +23,7 @@ type frontendDependencies struct { inspectShims func(hostenv.WSLLayout, []string) (wslshim.Result, error) lstat func(string) (os.FileInfo, error) executable func() (string, error) + absPath func(string) (string, error) evalSymlinks func(string) (string, error) getwd func() (string, error) interactive func() bool @@ -40,7 +40,7 @@ func runFrontend(ctx context.Context, invoked string, args []string, deps fronte return 0, fmt.Errorf("invalid native WSL tool invocation name %q", invoked) } if deps.currentLayout == nil || deps.checkLayout == nil || deps.checkRegistryRecovery == nil || deps.loadPolicy == nil || - deps.loadRegistry == nil || deps.inspectShims == nil || deps.lstat == nil || deps.executable == nil || deps.evalSymlinks == nil || deps.getwd == nil || deps.interactive == nil || deps.environ == nil { + deps.loadRegistry == nil || deps.inspectShims == nil || deps.lstat == nil || deps.executable == nil || deps.absPath == nil || deps.evalSymlinks == nil || deps.getwd == nil || deps.interactive == nil || deps.environ == nil { return 0, errors.New("native WSL frontend dependencies are incomplete") } layout, err := deps.currentLayout() @@ -76,7 +76,7 @@ func runFrontend(ctx context.Context, invoked string, args []string, deps fronte if err != nil { return 0, fmt.Errorf("locate native WSL running executable: %w", err) } - executable, err = filepath.Abs(executable) + executable, err = deps.absPath(executable) if err != nil { return 0, fmt.Errorf("canonicalize native WSL running executable: %w", err) } diff --git a/internal/wslrun/frontend_test.go b/internal/wslrun/frontend_test.go index 86d25ff..990f067 100644 --- a/internal/wslrun/frontend_test.go +++ b/internal/wslrun/frontend_test.go @@ -46,6 +46,7 @@ func TestRunFrontendUsesOnlyFixedLayoutAndManagedIdentity(t *testing.T) { }, lstat: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o600}, nil }, executable: func() (string, error) { return layout.BinaryPath, nil }, + absPath: func(value string) (string, error) { return value, nil }, evalSymlinks: func(value string) (string, error) { return value, nil }, getwd: func() (string, error) { return "/project", nil }, interactive: func() bool { return false }, @@ -83,6 +84,7 @@ func TestRunFrontendRejectsUnmanagedExecutableBeforeConfigOrDocker(t *testing.T) }, lstat: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o600}, nil }, executable: func() (string, error) { return "/tmp/cb", nil }, + absPath: func(value string) (string, error) { return value, nil }, evalSymlinks: func(value string) (string, error) { return value, nil }, getwd: func() (string, error) { return "/project", nil }, interactive: func() bool { return false }, diff --git a/internal/wslrun/run_linux.go b/internal/wslrun/run_linux.go index 0b9751f..10ca798 100644 --- a/internal/wslrun/run_linux.go +++ b/internal/wslrun/run_linux.go @@ -33,6 +33,7 @@ func productionFrontendDependencies() frontendDependencies { inspectShims: wslshim.Inspect, lstat: os.Lstat, executable: os.Executable, + absPath: filepath.Abs, evalSymlinks: filepath.EvalSymlinks, getwd: os.Getwd, interactive: terminal.Interactive, From aa8f6e9a07a328a16859b556dcad17f2ab54c9f9 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:31:40 +0100 Subject: [PATCH 3/8] Gate WSL activation pending orphan cleanup --- README.md | 13 +++---- docs/architecture.md | 7 +++- docs/roadmap-decisions.md | 5 +-- docs/roadmap-implementation-requirements.md | 16 +++++---- docs/security-model.md | 19 ++++++----- docs/wsl-process-contract.md | 10 ++++-- docs/wsl.md | 38 ++++++++++++--------- internal/hostenv/hostenv.go | 8 ++--- internal/hostenv/hostenv_test.go | 2 +- internal/wslinstall/install.go | 2 +- internal/wslinstall/install_test.go | 4 +-- internal/wslrun/runner.go | 23 +++++++++++-- internal/wslrun/runner_test.go | 19 ++++++++++- main.go | 4 +-- 14 files changed, 113 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index a513ba9..65fa244 100644 --- a/README.md +++ b/README.md @@ -71,7 +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 | **v2 runtime implemented; release qualification pending.** Native Linux shims use the fixed private WSL layout and Docker Desktop's WSL integration directly. Real WSL2 + Docker Desktop qualification remains before the v2 support claim. See [docs/wsl.md](docs/wsl.md) | +| WSL2 | **v2 runtime wired; activation gated.** The native Linux runtime uses the fixed private WSL layout and Docker Desktop's WSL integration directly, but production dispatch remains fail-closed until retained-container orphan reconciliation and real WSL2 qualification land. See [docs/wsl.md](docs/wsl.md) | | Windows 11 ARM64 | **CI/release-artifact/update-path qualified only, not supported yet.** Native tests/build/dispatch run on GitHub-hosted ARM64 hardware, the release workflow produces a reproducible ARM64 archive, and self-update selects and verifies that archive by `GOARCH`; real Docker Desktop ARM64 E2E qualification remains | | 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 | @@ -948,9 +948,9 @@ replacing foreign objects. A signed-registry policy disables automatic registry creation/upgrades and requires an already provisioned authenticated registry. The bootstrap executable must itself be a bounded, current-user-owned regular non-symlink file with safe executable permissions. This command still does not -contact Docker itself. Once install reports `RUNTIME ENABLED`, managed tool -shims perform their own fixed-layout and Docker Desktop proofs at invocation -time; real WSL2 release qualification remains. See [docs/wsl.md](docs/wsl.md). +contact Docker itself. Install reports the wired-but-gated runtime state; +managed tool dispatch remains fail-closed until orphan reconciliation and real +WSL2 qualification land. See [docs/wsl.md](docs/wsl.md). ### Self-update release selection @@ -1113,8 +1113,9 @@ benchmark methodology and the disposable-container tradeoff are in - Windows x64 + Docker Desktop (Linux containers) is the currently qualified release target. The native WSL2 runtime, fixed-layout installation, project mapping, managed volumes, Engine lifecycle, stdio/TTY, resize, signal and exit - propagation are implemented, but the v2 WSL support claim still requires real - WSL2 + Docker Desktop qualification. Windows ARM64 has native non-Docker CI + propagation are wired, but activation still requires retained-container + orphan reconciliation plus real WSL2 + Docker Desktop qualification. Windows + ARM64 has native non-Docker CI coverage and published release artifacts, but no Docker 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). diff --git a/docs/architecture.md b/docs/architecture.md index 50acce7..72b1563 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -491,7 +491,8 @@ identity constructed by this package and its complete labels plus local driver/scope. The package also preflights an entire stateful profile's project/shared binding set, re-proves the exact project root before deriving project identities, and ensures each distinct identity only after the complete -plan validates. Tool-time wiring is active; native state commands remain. +plan validates. Tool-time composition is wired but the host boundary keeps it +activation-gated until orphan reconciliation lands; native state commands remain. `internal/wslrun` is the native WSL vertical orchestrator. It requires the fixed layout, private registry, managed binary and exact invoked shim; resolves @@ -503,6 +504,10 @@ exit status, so daemon auto-remove cannot race fast tools. Non-TTY streams use strict Docker framing; TTY sessions use raw terminal mode, resize events and an explicit Linux signal-forwarding set. Any runtime failure cancels live I/O, proof-bound kills the container, waits for stop and performs non-force cleanup. +The production host boundary does not yet dispatch into this orchestrator: +SIGKILL of the host shim can bypass every in-process defer and strand a retained +container, so activation waits for proof-bound orphan reconciliation rather +than making an unsafe partial support claim. After the host runtime boundary is enforced, `cb self-update` is dispatched before machine policy and registry loading. Release selection therefore remains diff --git a/docs/roadmap-decisions.md b/docs/roadmap-decisions.md index 894cb47..bc38948 100644 --- a/docs/roadmap-decisions.md +++ b/docs/roadmap-decisions.md @@ -16,7 +16,7 @@ items from being repeatedly rediscovered as if they were immediately actionable. | RM-24 Python / uv | **Keep both** | Decision complete. Built-in `python`/`pip` keep the dedicated Python provider; `uv`/`uvx` remain separate opt-in stateful profiles. | | RM-26 Python global CLI exposure | **pipx yes; plain pip expose no** | Completed in PR #74. The separate stateful pipx profile and managed store shipped; project/compat `/venv/bin` remains intentionally unexposed. | | RM-34 Cargo expose enhancement | **Intentionally deferred** | Existing expose-all and explicit binary selection are sufficient. Reopen only for a concrete unmet use case. | -| WSL2 | **Native WSL frontend** | Fixed-layout install plus ordinary managed-tool runtime/Docker wiring are implemented. Native state commands, integration corpus and real WSL qualification remain. No Windows↔WSL path/state guessing. | +| WSL2 | **Native WSL frontend** | Fixed-layout install plus managed-tool runtime/Docker composition are wired behind the host gate. Retained-container orphan reconciliation, native state commands, integration corpus and real WSL qualification remain before activation. No Windows↔WSL path/state guessing. | | Enterprise policy | **Machine-owned constraint layer** | Foundation shipped in PR #75. Authenticated registry and image-trust follow-ups must extend this boundary and cannot be weakened by lower layers. | | Image trust | **Policy-driven Sigstore/cosign at lock time** | Ready after signed-registry policy. Digest locking remains default where policy permits. Required trust never silently falls back to digest-only. | | Per-project overlays | **Explicit digest-bound, add-only trust model** | Implementation-ready on the merged policy foundation. Initial overlays exclude host mounts, env prefixes and shared cross-project volumes. | @@ -344,7 +344,8 @@ is not completion. layout/state identity are merged in PRs #77 and #83; - explicit read-only/apply Linux ownership, permission and symlink layout preparation plus the fixed-path native install/config lifecycle are - implemented; ordinary managed tool shims now enter the native runtime; + implemented; ordinary managed tool dispatch is composed but remains + activation-gated pending orphan reconciliation; - Docker Desktop WSL integration proof, proof-bound bounded control requests, the separately constrained attach transport, strict raw-stream decoder, exact container inspection, wait, TTY-resize, signal, start, creation and diff --git a/docs/roadmap-implementation-requirements.md b/docs/roadmap-implementation-requirements.md index 2de0769..adf956b 100644 --- a/docs/roadmap-implementation-requirements.md +++ b/docs/roadmap-implementation-requirements.md @@ -79,7 +79,7 @@ The minimum delivery gate for a code change is: | Enterprise policy | **Foundation and signed registry shipped / image trust remains** | PRs #75 and #84 shipped the machine-owned constraint layer and authenticated registry; image trust remains | | Image trust | **Online/offline production and runtime authorization implemented / private-registry work remains** | Add an explicit private-registry credential bridge | | Plugin/provider architecture | **Intentionally deferred** | Reopen only after at least two real integrations cannot fit the declarative model | -| WSL2 | **Native tool runtime implemented / state commands and qualification remaining** | The fail-closed host boundary, fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup and exit propagation are wired for managed tool shims. Native state-management commands, integration corpus and real WSL2 + Docker Desktop qualification remain. | +| WSL2 | **Native tool runtime wired / activation remaining** | The fixed install/config/shim lifecycle, project proof/mapping, namespaced tool-time volumes, direct Docker Desktop Engine lifecycle, stdin/output framing, raw TTY, resize, signal forwarding, retained-container cleanup and exit propagation are composed behind the fail-closed host gate. Retained-container orphan reconciliation, native state-management commands, integration corpus and real WSL2 + Docker Desktop qualification remain before activation. | | Per-project overlays | **Completed in PR #80** | Add-only digest-bound trust model shipped on the merged enterprise-policy foundation | | Release SBOM | **Conditionally deferred** | Trigger on shipped third-party/runtime dependencies or concrete compliance/consumer demand | | Snyk | **Conditionally deferred** | Trigger only for a real coverage gap plus owner/account/token and triage/outage policy | @@ -605,16 +605,17 @@ PR #77 shipped the fail-closed host runtime boundary and explicit Windows/WSL separation. The fixed native Linux config/shim/state layout can now be checked or prepared explicitly, and `cb wsl install --check|--apply` composes it with fixed-path policy/registry loading, managed-binary installation and -registry-derived management/tool-shim reconciliation. Ordinary managed tool -execution now composes canonical project storage proof and argument mapping, +registry-derived management/tool-shim reconciliation. The wired managed-tool +path composes canonical project storage proof and argument mapping, namespaced stateful/Python volume creation, fixed-lock image authorization, Docker Desktop WSL integration proof, create/attach/start/wait/resize/signal/ cleanup, strict non-TTY output decoding, raw TTY mode, terminal resize events, host-signal forwarding and exact exit-code propagation. Containers are retained until wait records the exit status and are then removed through the proof-bound -cleanup path, avoiding an auto-remove race for fast tools. Native state-command -integration, project/cross-boundary integration coverage and real WSL -qualification remain. +cleanup path, avoiding an auto-remove race for fast tools. Because uncatchable +host-shim death can bypass that in-process cleanup, production activation waits +for proof-bound orphan reconciliation. Native state-command integration, +project/cross-boundary integration coverage and real WSL qualification remain. Implementation must define native config/shim location, Docker endpoint, project identity, named-volume behavior, file permissions, case sensitivity, @@ -725,7 +726,8 @@ in PR #91. 2. Signed-registry enterprise policy. 3. Image trust at lock time, after signed-registry policy merges. 4. Remaining RM-31 real published-release/self-test E2E qualification. -5. Remaining native WSL state commands, integration corpus and real E2E. +5. Native WSL retained-container orphan reconciliation, remaining state + commands, integration corpus and real E2E. 6. RM-30 Authenticode only after certificate/protected-signing prerequisites exist. 7. RM-29 real Windows-on-Arm + Docker Desktop qualification last; do not delay higher-value work for it. diff --git a/docs/security-model.md b/docs/security-model.md index 01dbaac..3f324e9 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -91,20 +91,20 @@ readable, and dangerous to let others edit. fingerprint, mechanism and signer/key identity. Missing or stale evidence never falls back to digest-only locking. Private-registry credentials remain excluded until an explicit non-ambient bridge is implemented. -- **Fail-closed host boundary.** Non-bootstrap work runs only in a native - Windows process or a distribution-identified native WSL2 process. Windows - binaries launched through detected WSL interoperability, WSL1, standalone - Linux and other hosts refuse before registry or Docker work. WSL2 +- **Fail-closed host boundary.** Non-bootstrap work currently runs only in a + native Windows process. Windows binaries launched through detected WSL + interoperability, WSL1, standalone Linux and other hosts refuse before + registry or Docker work. WSL2 classification requires Microsoft WSL2 kernel markers and a canonical `WSL_DISTRO_NAME`; environment variables alone cannot turn ordinary Linux into a supported host. Native WSL preparation validates or creates only the fixed current-user layout and loads no registry or machine policy. Installation validates that layout before fixed-path policy/registry access, authenticates signed registries when required, and reconciles only the fixed - managed binary and provenance-checked symlinks. Ordinary managed tool - execution then revalidates that fixed installation and uses the proof-bound - Docker Desktop WSL transport; unsupported native management commands remain - rejected. + managed binary and provenance-checked symlinks. The ordinary managed-tool + runtime is composed and tested but remains behind this host gate until + retained-container orphan reconciliation and real qualification land; + unsupported native management commands remain rejected. - **Native WSL installation does not adopt ambient files.** The bootstrap executable is the exact OS-reported running image and must be a bounded, current-user-owned regular non-symlink file with safe executable permissions. @@ -173,6 +173,9 @@ readable, and dangerous to let others edit. failure cancels live operations, sends SIGKILL only to the immutable owned container, waits under a fresh bound and then invokes the same non-force proof-bound removal. Cleanup errors are never hidden by the original failure. + An uncatchable host-shim SIGKILL can bypass every in-process cleanup path, so + production activation remains gated until a separate proof-bound orphan + reconciliation mechanism handles both running and stopped retained objects. - **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 diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 0104035..3c32e4b 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -1,8 +1,9 @@ # Native WSL process contract This document defines the process semantics implemented by ContainerBin's -native-Linux frontend inside WSL2. Managed tool shims are enabled in this tree; -the v2 support claim still requires real WSL2 + Docker Desktop qualification. +native-Linux frontend inside WSL2. The runtime is composed and covered in this +tree, but production dispatch remains activation-gated until retained-container +orphan reconciliation and real WSL2 + Docker Desktop qualification land. The corresponding Windows behavior is documented separately in [the Windows shell/process contract](shell-contract.md). @@ -64,6 +65,11 @@ byte-for-byte and half-closed at EOF while output remains open. Docker's strict raw-stream framing is decoded into the caller's separate stdout and stderr; a truncated or malformed frame is an infrastructure failure. +If stdin copying has already completed with an error when the tool exits, that +error wins over the tool status so truncated piped input is not reported as +success. ContainerBin does not wait indefinitely for a terminal or pipe reader +that remains blocked after the tool and output stream have both completed. + TTY mode is selected only when both stdin and stdout are character devices. The native terminal enters raw mode, the initial size is applied after start, and each `SIGWINCH` triggers a fresh positive row/column resize. Docker's TTY diff --git a/docs/wsl.md b/docs/wsl.md index 253d9df..cd8184e 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -6,15 +6,15 @@ integration. A Windows `cb.exe` launched through WSL interoperability is not the WSL frontend, and standalone Linux remains a separate, demand-gated product. The implementation establishes the runtime boundary, fixed native-WSL layout, -explicit install/config lifecycle and ordinary managed-tool execution through -Docker Desktop's Engine socket. It does not yet make a release support claim: -real WSL2 + Docker Desktop qualification and the remaining native management -state lifecycle still have to land before v2.0.0. +explicit install/config lifecycle and the complete managed-tool composition +through Docker Desktop's Engine socket. Production dispatch remains fail-closed +until retained-container orphan reconciliation, the remaining native management +state lifecycle and real WSL2 + Docker Desktop qualification land. ## Runtime classification - Native Windows is the currently qualified release frontend. The native WSL2 - frontend is implemented here but remains qualification-gated for v2.0.0. + runtime is wired here but remains activation-gated for v2.0.0. - 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 @@ -30,9 +30,10 @@ state lifecycle still have to land before v2.0.0. - `cb version`, `cb help` and `cb config` remain bootstrap-safe for diagnosis; they perform no Docker or registry mutation and return before host enforcement. - `cb wsl prepare --check|--apply` and `cb wsl install --check|--apply` are the - native-WSL management surface. Managed tool shims are enabled after install; - other `cb` management commands remain explicitly unavailable rather than - falling through to Windows-oriented Docker CLI, path or state behavior. + native-WSL management surface. Managed tool shims are installed, but their + runtime dispatch remains gated; other `cb` management commands remain + explicitly unavailable rather than falling through to Windows-oriented + Docker CLI, path or state behavior. Environment variables alone never promote an ordinary Linux kernel to WSL2. Custom kernels that remove the Microsoft WSL2 identity markers fail closed; @@ -113,8 +114,8 @@ no-op. The management shim and every registry-derived tool shim are then created only when missing and fully revalidated. Foreign files, owners, targets or unsafe modes stop the transaction instead of being repaired or replaced. The install command itself performs no Docker request. After a successful -apply and revalidation, its managed tool shims are eligible for ordinary -runtime execution. +apply and revalidation, its managed tool shims remain activation-gated by the +host boundary until orphan reconciliation and qualification land. An interruption before the final binary rename can leave a current-user-owned `.cb-install-.tmp` regular file in the private binary directory. ContainerBin does not sweep filename lookalikes without stronger provenance; @@ -185,7 +186,8 @@ preserves relative package patterns such as `./...`, maps absolute package patterns, and rejects external, symlinked or cross-mount paths instead of creating implicit mounts or translating Windows spellings. Ambiguous bare arguments remain unchanged so a project entry cannot replace a tool subcommand. -The native runtime consumes this exact proof for every project-mode tool. +The wired native runtime consumes this exact proof for every project-mode tool; +production dispatch remains gated. A WSL-filesystem project root must be on the same filesystem device as the distribution root. A Windows-filesystem project root must be below a proven default @@ -318,7 +320,7 @@ on partial-result warnings, duplicates or results outside both namespace filters. Because the Engine applies those label and name filters together, discovery deliberately does not report a same-name foreign volume that omits the namespace label; exact-name inspect or ensure still finds and rejects that -collision. Tool execution now plans the complete state set before mutation, +collision. The wired tool path plans the complete state set before mutation, ensures each exact volume, and passes its complete labels into container creation. Stateful project/shared profiles and the Python provider's per-project venv, namespace-shared compatibility venv and pip cache are wired. @@ -326,15 +328,17 @@ per-project venv, namespace-shared compatibility venv and pip cache are wired. ## Remaining before the v2 WSL support claim -Ordinary managed tool execution is implemented. Release qualification still -requires all of the following: +The ordinary managed tool path is implemented behind the host gate. Activation +and release qualification still require all of the following: -1. complete the native state-management subset required for safe supported +1. add proof-bound retained-container orphan reconciliation so an uncatchable + host-shim death cannot strand a running or stopped tool container; +2. complete the native state-management subset required for safe supported cleanup and diagnostics; every consumer must construct and match the complete distribution/machine/user identity; -2. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation +3. Windows-filesystem and WSL-filesystem project tests plus mixed-invocation rejection; and -3. real WSL2 + Docker Desktop end-to-end qualification before any support claim. +4. real WSL2 + Docker Desktop end-to-end qualification before any support claim. The WSL runtime deliberately rejects `host_mounts`: that registry field uses a Windows drive-path grammar and silently reinterpreting it as Linux would violate diff --git a/internal/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 3b122a2..7567c00 100644 --- a/internal/hostenv/hostenv.go +++ b/internal/hostenv/hostenv.go @@ -49,9 +49,9 @@ func Current() (Runtime, error) { return classify(goos, kernelRelease, os.Getenv("WSL_DISTRO_NAME"), os.Getenv("WSL_INTEROP")), nil } -// RequireFrontend enforces the supported host boundary. Native Windows and a -// distribution-identified native WSL2 process are frontends; Windows interop, -// WSL1, ambiguous Microsoft kernels and standalone Linux remain rejected. +// RequireFrontend enforces the supported host boundary. Native Windows is +// enabled. Native WSL2 is classified precisely but remains activation-gated +// until retained-container orphan reconciliation and real qualification land. func RequireFrontend() error { return requireFrontend(Current()) } @@ -76,7 +76,7 @@ func requireFrontend(info Runtime, probeErr error) error { if err := validateDistroIdentity(info.Distro); err != nil { return fmt.Errorf("native WSL2 distribution identity cannot be proven: %w", err) } - return nil + return fmt.Errorf("native WSL2 distribution %q was detected; the tool runtime is wired but activation is gated until orphan-container reconciliation and real Docker Desktop qualification land", info.Distro) case WSL1Native: return errors.New("WSL1 is unsupported; the native frontend requires WSL2 and Docker Desktop WSL integration") case WSLUnrecognized: diff --git a/internal/hostenv/hostenv_test.go b/internal/hostenv/hostenv_test.go index 2ec2379..853fbf6 100644 --- a/internal/hostenv/hostenv_test.go +++ b/internal/hostenv/hostenv_test.go @@ -50,7 +50,7 @@ func TestRequireFrontend(t *testing.T) { {name: "wsl2 missing distro", info: Runtime{Kind: WSL2Native}, want: "distribution identity cannot be proven"}, {name: "wsl2 whitespace distro", info: Runtime{Kind: WSL2Native, Distro: " \t"}, want: "distribution identity cannot be proven"}, {name: "wsl2 noncanonical distro", info: Runtime{Kind: WSL2Native, Distro: " Ubuntu"}, want: "distribution identity cannot be proven"}, - {name: "wsl2", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}}, + {name: "wsl2 gated", info: Runtime{Kind: WSL2Native, Distro: "Ubuntu"}, want: "activation is gated"}, {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"}, diff --git a/internal/wslinstall/install.go b/internal/wslinstall/install.go index ccd3fba..e273785 100644 --- a/internal/wslinstall/install.go +++ b/internal/wslinstall/install.go @@ -307,7 +307,7 @@ func printPlan(out io.Writer, plan Plan, applied bool) error { fmt.Fprintln(&report, "status: APPLY REQUIRED") fmt.Fprintln(&report, "apply: cb wsl install --apply") } - fmt.Fprintln(&report, "frontend: RUNTIME ENABLED (release support requires real WSL qualification)") + fmt.Fprintln(&report, "frontend: RUNTIME WIRED; ACTIVATION GATED (orphan reconciliation and real WSL qualification remain)") if _, err := io.WriteString(out, report.String()); err != nil { return fmt.Errorf("write native WSL installation report: %w", err) } diff --git a/internal/wslinstall/install_test.go b/internal/wslinstall/install_test.go index f6942b7..459960c 100644 --- a/internal/wslinstall/install_test.go +++ b/internal/wslinstall/install_test.go @@ -54,7 +54,7 @@ func TestCheckIsReadOnlyAndReportsRequiredActions(t *testing.T) { if mutatingLoadCalled { t.Fatal("read-only check used mutating registry load") } - for _, want := range []string{"read-only; no files changed", "registry: create", "binary: create", "management: create", "APPLY REQUIRED", "frontend: RUNTIME ENABLED"} { + for _, want := range []string{"read-only; no files changed", "registry: create", "binary: create", "management: create", "APPLY REQUIRED", "frontend: RUNTIME WIRED; ACTIVATION GATED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } @@ -236,7 +236,7 @@ func TestApplyComposesRegistryBinaryAndShimLifecycle(t *testing.T) { if !prepared || !locked || !registryExists || !binaryReady || !managementReady || !shimsReady { t.Fatalf("incomplete lifecycle: prepared=%t locked=%t registry=%t binary=%t management=%t shims=%t", prepared, locked, registryExists, binaryReady, managementReady, shimsReady) } - for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: RUNTIME ENABLED"} { + for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: RUNTIME WIRED; ACTIVATION GATED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index a0c0ff0..2f076a4 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -125,7 +125,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code // non-force removal safely distinguishes an already-stopped container // from one that is still running without guessing or force-deleting it. if err := deps.remove(cleanupCtx, container); err != nil { - retErr = errors.Join(retErr, lifecycleErr, fmt.Errorf("clean up ambiguously running native WSL tool container: %w", err)) + retErr = errors.Join(retErr, fmt.Errorf("clean up ambiguously running native WSL tool container: %w", err)) } else { removed = true running = false @@ -136,6 +136,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code retErr = errors.Join(retErr, fmt.Errorf("clean up native WSL tool container: %w", err)) } } + retErr = errors.Join(retErr, lifecycleErr) }() events, stop, err := deps.startEvents(plan.spec.TTY) @@ -204,7 +205,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code if *outputResult != nil { return 0, fmt.Errorf("copy native WSL tool output: %w", *outputResult) } - return result.code, nil + return finishToolResult(result.code, inputDone) } timer := time.NewTimer(outputDrainTimeout) select { @@ -213,7 +214,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code if outputErr != nil { return 0, fmt.Errorf("copy native WSL tool output: %w", outputErr) } - return result.code, nil + return finishToolResult(result.code, inputDone) case <-timer.C: return 0, errors.New("native WSL attach stream did not close after the container exited") case <-ctx.Done(): @@ -253,3 +254,19 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code } } } + +func finishToolResult(code int, inputDone <-chan error) (int, error) { + if inputDone == nil { + return code, nil + } + select { + case err := <-inputDone: + if err != nil && !errors.Is(err, io.ErrClosedPipe) { + return 0, fmt.Errorf("copy native WSL tool input: %w", err) + } + default: + // A terminal or pipe reader can remain blocked after the tool exits. + // Do not turn successful process completion into an unbounded stdin wait. + } + return code, nil +} diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index 537d95c..f66d8a3 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -188,7 +188,7 @@ func TestExecuteToolCleansUpAfterAmbiguousStartFailure(t *testing.T) { Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", RetainUntilCleanup: true, }} _, err := executeTool(context.Background(), plan, deps) - if err == nil || !strings.Contains(err.Error(), "start native WSL tool container: connection closed") { + if err == nil || !strings.Contains(err.Error(), "start native WSL tool container: connection closed") || !strings.Contains(err.Error(), "stop native WSL tool container after failure: container is not running") { t.Fatalf("start failure = %v", err) } if got, want := calls, []string{"create", "events", "attach", "start", "signal", "remove"}; !reflect.DeepEqual(got, want) { @@ -196,6 +196,23 @@ func TestExecuteToolCleansUpAfterAmbiguousStartFailure(t *testing.T) { } } +func TestExecuteToolReportsCompletedInputFailureBeforeSuccessfulExit(t *testing.T) { + inputDone := make(chan error, 1) + inputDone <- errors.New("source read failed") + _, err := finishToolResult(0, inputDone) + if err == nil || !strings.Contains(err.Error(), "copy native WSL tool input: source read failed") { + t.Fatalf("input failure = %v", err) + } +} + +func TestFinishToolResultDoesNotWaitForBlockedInput(t *testing.T) { + inputDone := make(chan error) + code, err := finishToolResult(23, inputDone) + if err != nil || code != 23 { + t.Fatalf("finishToolResult() = (%d, %v), want (23, nil)", code, err) + } +} + func successfulRunDependencies(t *testing.T, stream *fakeAttach, stdout, stderr io.Writer, calls *[]string) runDependencies { t.Helper() return runDependencies{ diff --git a/main.go b/main.go index 62062a2..11d05ec 100644 --- a/main.go +++ b/main.go @@ -479,8 +479,8 @@ Commands: cb help print this help without loading the registry Native WSL2: - Bootstrap, cb wsl ..., and managed tool shims are enabled. Other management - commands remain Windows-only until their native state/update contracts land. + Bootstrap and cb wsl ... are enabled. The managed-tool runtime is wired but + activation awaits orphan reconciliation and real Docker Desktop qualification. Registry: %s From e47dba5cfebfec2adcc4b412de4f29e7c1e32a75 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:46:55 +0100 Subject: [PATCH 4/8] Harden WSL terminal and completion races --- docs/wsl-process-contract.md | 26 +++++++---- docs/wsl.md | 8 ++-- internal/wslinstall/install.go | 3 +- internal/wslinstall/install_test.go | 10 +++- internal/wslrun/run_linux.go | 3 +- internal/wslrun/runner.go | 26 +++++++---- internal/wslrun/runner_test.go | 65 ++++++++++++++++++++++++++ internal/wslrun/terminal_linux.go | 14 +++++- internal/wslrun/terminal_linux_test.go | 44 +++++++++++++++++ 9 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 internal/wslrun/terminal_linux_test.go diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 3c32e4b..1d4686c 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -70,11 +70,13 @@ error wins over the tool status so truncated piped input is not reported as success. ContainerBin does not wait indefinitely for a terminal or pipe reader that remains blocked after the tool and output stream have both completed. -TTY mode is selected only when both stdin and stdout are character devices. -The native terminal enters raw mode, the initial size is applied after start, -and each `SIGWINCH` triggers a fresh positive row/column resize. Docker's TTY -stream is unframed and is written to stdout; terminal state is restored on every -return path. +TTY mode is selected only when stdin accepts a real Linux termios query; +character-device mode alone is insufficient because `/dev/null` and `/dev/zero` +are not terminals. Stdout may be redirected. The native stdin terminal enters +raw mode, provides the initial size after start, and each `SIGWINCH` triggers a +fresh positive row/column resize from that same terminal. Docker's TTY stream is +unframed and is written to stdout; terminal state is restored on every return +path. The runtime waits up to five seconds for the attach stream to drain after the Engine reports exit. Failure to drain is an infrastructure error rather than a @@ -82,14 +84,22 @@ silent loss of trailing output. ## Signals, failures and exit status -The host intercepts HUP, INT, QUIT, USR1, USR2, TERM, CONT and TSTP and forwards -their exact numeric Linux value to the owned container. `SIGWINCH` is consumed -as a resize event and is not forwarded. KILL and STOP cannot be intercepted. +The host intercepts HUP, INT, QUIT, USR1, USR2, TERM, CONT, TSTP and PIPE and +forwards their exact numeric Linux value to the owned container. Intercepting +`SIGPIPE` also ensures a broken output pipe returns through the normal `EPIPE` +error and deferred cleanup instead of terminating the shim first. `SIGWINCH` is +consumed as a resize event and is not forwarded. KILL and STOP cannot be +intercepted. The tool's Engine status passes through unchanged, including conventional signal-derived statuses such as 130 when the container process returns them. ContainerBin does not invent a second mapping from host signals. +An Engine 404/409 from an initial or queued resize/signal operation is treated +as a possible completion race, not as the final outcome. The already-started +Engine wait remains authoritative: a normal wait result preserves the tool exit +status, while a vanished container still makes wait fail closed. + If attach, output, resize, signal forwarding, wait or the caller context fails while the container is running, ContainerBin cancels the live operations, sends SIGKILL through the proof-bound signal endpoint, waits for the retained diff --git a/docs/wsl.md b/docs/wsl.md index cd8184e..bc37d02 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -286,9 +286,11 @@ removal disabled, and verifies absence afterward. If daemon-side auto-remove wins the race between inspection and deletion, a DELETE 404 succeeds only after a fresh proof-bound inspection confirms absence. The runtime attaches before start, streams stdin with an explicit half-close, -decodes non-TTY stdout/stderr framing, uses raw terminal mode for TTY sessions, -applies the initial size, consumes `SIGWINCH`, and forwards HUP, INT, QUIT, -USR1, USR2, TERM, CONT and TSTP numerically to the exact owned container. It +decodes non-TTY stdout/stderr framing, selects TTY only when stdin accepts a +real termios query, uses raw terminal mode, applies the initial size from stdin, +consumes `SIGWINCH`, and forwards HUP, INT, QUIT, USR1, USR2, TERM, CONT, TSTP +and PIPE numerically to the exact owned container. Completion-race 404/409 +responses from resize/signal are deferred to the authoritative Engine wait. It waits for the Engine exit status, drains output, restores the terminal and performs proof-bound cleanup; the tool's `0..255` exit code passes through. Infrastructure or stream failure cancels the live wait, sends SIGKILL through diff --git a/internal/wslinstall/install.go b/internal/wslinstall/install.go index e273785..a4542b9 100644 --- a/internal/wslinstall/install.go +++ b/internal/wslinstall/install.go @@ -303,11 +303,12 @@ func printPlan(out io.Writer, plan Plan, applied bool) error { } if plan.ready() { fmt.Fprintln(&report, "status: INSTALLATION READY") + fmt.Fprintln(&report, "frontend: INSTALLED; RUNTIME WIRED; ACTIVATION GATED (orphan reconciliation and real WSL qualification remain)") } else { fmt.Fprintln(&report, "status: APPLY REQUIRED") fmt.Fprintln(&report, "apply: cb wsl install --apply") + fmt.Fprintln(&report, "frontend: INSTALL REQUIRED; RUNTIME WIRED; ACTIVATION GATED") } - fmt.Fprintln(&report, "frontend: RUNTIME WIRED; ACTIVATION GATED (orphan reconciliation and real WSL qualification remain)") if _, err := io.WriteString(out, report.String()); err != nil { return fmt.Errorf("write native WSL installation report: %w", err) } diff --git a/internal/wslinstall/install_test.go b/internal/wslinstall/install_test.go index 459960c..9ca31ad 100644 --- a/internal/wslinstall/install_test.go +++ b/internal/wslinstall/install_test.go @@ -54,11 +54,14 @@ func TestCheckIsReadOnlyAndReportsRequiredActions(t *testing.T) { if mutatingLoadCalled { t.Fatal("read-only check used mutating registry load") } - for _, want := range []string{"read-only; no files changed", "registry: create", "binary: create", "management: create", "APPLY REQUIRED", "frontend: RUNTIME WIRED; ACTIVATION GATED"} { + for _, want := range []string{"read-only; no files changed", "registry: create", "binary: create", "management: create", "APPLY REQUIRED", "frontend: INSTALL REQUIRED; RUNTIME WIRED; ACTIVATION GATED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } } + if strings.Contains(out.String(), "frontend: INSTALLED") { + t.Fatalf("incomplete installation reported installed:\n%s", out.String()) + } } func TestCheckReportsRegistryRecoveryWithoutMutation(t *testing.T) { @@ -236,11 +239,14 @@ func TestApplyComposesRegistryBinaryAndShimLifecycle(t *testing.T) { if !prepared || !locked || !registryExists || !binaryReady || !managementReady || !shimsReady { t.Fatalf("incomplete lifecycle: prepared=%t locked=%t registry=%t binary=%t management=%t shims=%t", prepared, locked, registryExists, binaryReady, managementReady, shimsReady) } - for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: RUNTIME WIRED; ACTIVATION GATED"} { + for _, want := range []string{"applied and revalidated", "INSTALLATION READY", "frontend: INSTALLED; RUNTIME WIRED; ACTIVATION GATED"} { if !strings.Contains(out.String(), want) { t.Fatalf("output missing %q:\n%s", want, out.String()) } } + if strings.Contains(out.String(), "frontend: INSTALL REQUIRED") { + t.Fatalf("ready installation still reported apply required:\n%s", out.String()) + } } func TestApplyRejectsBootstrapBeforeRegistryMutation(t *testing.T) { diff --git a/internal/wslrun/run_linux.go b/internal/wslrun/run_linux.go index 10ca798..b15cbd8 100644 --- a/internal/wslrun/run_linux.go +++ b/internal/wslrun/run_linux.go @@ -10,7 +10,6 @@ import ( "github.com/AviBackToBlack/container-bin/internal/policy" "github.com/AviBackToBlack/container-bin/internal/registry" - "github.com/AviBackToBlack/container-bin/internal/terminal" "github.com/AviBackToBlack/container-bin/internal/wsldocker" "github.com/AviBackToBlack/container-bin/internal/wslfs" "github.com/AviBackToBlack/container-bin/internal/wslshim" @@ -36,7 +35,7 @@ func productionFrontendDependencies() frontendDependencies { absPath: filepath.Abs, evalSymlinks: filepath.EvalSymlinks, getwd: os.Getwd, - interactive: terminal.Interactive, + interactive: interactiveHostTerminal, environ: os.Environ, plan: productionPlanDependencies(), run: runDependencies{ diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index 2f076a4..6f04120 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/http" "time" "github.com/AviBackToBlack/container-bin/internal/wsldocker" @@ -161,8 +162,13 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code if err := deps.start(runCtx, container.id); err != nil { return 0, fmt.Errorf("start native WSL tool container: %w", err) } + waitDone := make(chan waitResult, 1) + go func() { + waitCode, waitErr := deps.wait(runCtx, container.id) + waitDone <- waitResult{code: waitCode, err: waitErr} + }() if plan.spec.TTY { - if err := deps.resize(runCtx, container.id, term.height, term.width); err != nil { + if err := deps.resize(runCtx, container.id, term.height, term.width); err != nil && !containerCompletionRace(err) { return 0, fmt.Errorf("set initial native WSL container terminal size: %w", err) } } @@ -187,12 +193,6 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code } inputDone <- closeErr }() - waitDone := make(chan waitResult, 1) - go func() { - waitCode, waitErr := deps.wait(runCtx, container.id) - waitDone <- waitResult{code: waitCode, err: waitErr} - }() - var outputResult *error for { select { @@ -241,11 +241,11 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code return 0, event.err } if event.resize { - if err := deps.resize(runCtx, container.id, event.height, event.width); err != nil { + if err := deps.resize(runCtx, container.id, event.height, event.width); err != nil && !containerCompletionRace(err) { return 0, fmt.Errorf("resize native WSL tool terminal: %w", err) } } else if event.signal != 0 { - if err := deps.signal(runCtx, container.id, event.signal); err != nil { + if err := deps.signal(runCtx, container.id, event.signal); err != nil && !containerCompletionRace(err) { return 0, fmt.Errorf("forward signal %d to native WSL tool container: %w", event.signal, err) } } @@ -255,6 +255,14 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code } } +func containerCompletionRace(err error) bool { + var apiErr *wsldocker.APIError + if !errors.As(err, &apiErr) { + return false + } + return apiErr.StatusCode == http.StatusNotFound || apiErr.StatusCode == http.StatusConflict +} + func finishToolResult(code int, inputDone <-chan error) (int, error) { if inputDone == nil { return code, nil diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index f66d8a3..5fde31c 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "errors" "io" + "net/http" "reflect" "strings" "sync" @@ -136,6 +137,70 @@ func TestExecuteToolAppliesTTYSizeAndForwardsHostEvents(t *testing.T) { } } +func TestExecuteToolPreservesFastTTYExitAcrossInitialResizeRace(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader([]byte("done")), writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps.prepareTerminal = func(bool) (terminalControl, error) { + return terminalControl{height: 24, width: 80, restore: func() error { return nil }}, nil + } + deps.resize = func(context.Context, string, uint16, uint16) error { + return &wsldocker.APIError{StatusCode: http.StatusConflict, Message: "container is not running"} + } + deps.wait = func(context.Context, string) (int, error) { + calls = append(calls, "wait") + return 7, nil + } + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", TTY: true, + }} + code, err := executeTool(context.Background(), plan, deps) + if err != nil || code != 7 || stdout.String() != "done" { + t.Fatalf("fast TTY result code=%d stdout=%q err=%v", code, stdout.String(), err) + } +} + +func TestExecuteToolPreservesExitAcrossQueuedControlEvents(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader([]byte("done")), writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + events := make(chan hostEvent, 2) + events <- hostEvent{resize: true, height: 40, width: 120} + events <- hostEvent{signal: 2} + close(events) + deps.startEvents = func(bool) (<-chan hostEvent, func(), error) { return events, func() {}, nil } + deps.prepareTerminal = func(bool) (terminalControl, error) { + return terminalControl{height: 24, width: 80, restore: func() error { return nil }}, nil + } + resizeCalls := 0 + deps.resize = func(context.Context, string, uint16, uint16) error { + resizeCalls++ + if resizeCalls == 1 { + return nil + } + return &wsldocker.APIError{StatusCode: http.StatusConflict, Message: "container is not running"} + } + controlsDone := make(chan struct{}) + deps.signal = func(context.Context, string, int) error { + close(controlsDone) + return &wsldocker.APIError{StatusCode: http.StatusNotFound, Message: "no such container"} + } + deps.wait = func(context.Context, string) (int, error) { + calls = append(calls, "wait") + <-controlsDone + return 9, nil + } + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", TTY: true, + }} + code, err := executeTool(context.Background(), plan, deps) + if err != nil || code != 9 || stdout.String() != "done" { + t.Fatalf("queued-event result code=%d stdout=%q err=%v", code, stdout.String(), err) + } +} + func TestExecuteToolForceStopsOwnedContainerAfterStreamFailure(t *testing.T) { malformed := rawFrame(1, "bad") malformed[1] = 1 diff --git a/internal/wslrun/terminal_linux.go b/internal/wslrun/terminal_linux.go index 517945f..551e08e 100644 --- a/internal/wslrun/terminal_linux.go +++ b/internal/wslrun/terminal_linux.go @@ -32,7 +32,7 @@ func prepareHostTerminal(tty bool) (terminalControl, error) { if err := setTermios(stdinFD, raw); err != nil { return terminalControl{}, fmt.Errorf("enter native WSL raw terminal mode: %w", err) } - height, width, err := terminalSize(os.Stdout.Fd()) + height, width, err := terminalSize(stdinFD) if err != nil { _ = setTermios(stdinFD, original) return terminalControl{}, fmt.Errorf("read native WSL terminal size: %w", err) @@ -51,11 +51,21 @@ func prepareHostTerminal(tty bool) (terminalControl, error) { }, nil } +// interactiveHostTerminal requires a real Linux terminal on stdin. Character +// device mode alone is insufficient because /dev/null and /dev/zero also set +// os.ModeCharDevice but reject terminal ioctls. Stdout may be redirected; TTY +// sizing is taken from the controlling stdin terminal. +func interactiveHostTerminal() bool { + _, err := getTermios(os.Stdin.Fd()) + return err == nil +} + func startHostEvents(tty bool) (<-chan hostEvent, func(), error) { signals := make(chan os.Signal, 16) watched := []os.Signal{ syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGTERM, syscall.SIGCONT, syscall.SIGTSTP, + syscall.SIGPIPE, } if tty { watched = append(watched, syscall.SIGWINCH) @@ -87,7 +97,7 @@ func startHostEvents(tty bool) (<-chan hostEvent, func(), error) { } event := hostEvent{signal: int(number)} if number == syscall.SIGWINCH { - height, width, err := terminalSize(os.Stdout.Fd()) + height, width, err := terminalSize(os.Stdin.Fd()) event = hostEvent{resize: true, height: height, width: width} if err != nil { event = hostEvent{err: fmt.Errorf("read resized native WSL terminal: %w", err)} diff --git a/internal/wslrun/terminal_linux_test.go b/internal/wslrun/terminal_linux_test.go new file mode 100644 index 0000000..095eb06 --- /dev/null +++ b/internal/wslrun/terminal_linux_test.go @@ -0,0 +1,44 @@ +//go:build linux + +package wslrun + +import ( + "os" + "syscall" + "testing" + "time" +) + +func TestInteractiveHostTerminalRejectsNonTerminalCharacterDevice(t *testing.T) { + device, err := os.Open("/dev/null") + if err != nil { + t.Fatal(err) + } + defer device.Close() + original := os.Stdin + os.Stdin = device + defer func() { os.Stdin = original }() + + if interactiveHostTerminal() { + t.Fatal("/dev/null was classified as an interactive terminal") + } +} + +func TestStartHostEventsInterceptsSIGPIPE(t *testing.T) { + events, stop, err := startHostEvents(false) + if err != nil { + t.Fatal(err) + } + defer stop() + if err := syscall.Kill(os.Getpid(), syscall.SIGPIPE); err != nil { + t.Fatal(err) + } + select { + case event := <-events: + if event.err != nil || event.signal != int(syscall.SIGPIPE) || event.resize { + t.Fatalf("SIGPIPE event = %#v", event) + } + case <-time.After(2 * time.Second): + t.Fatal("SIGPIPE was not intercepted") + } +} From 36f696551d6b8fe3cb056dc388aaf1acede81267 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:22:04 +0100 Subject: [PATCH 5/8] Handle unknown WSL terminal dimensions --- docs/architecture.md | 2 +- docs/wsl-process-contract.md | 16 ++++---- docs/wsl.md | 16 ++++---- internal/wslrun/runner.go | 2 +- internal/wslrun/runner_test.go | 21 ++++++++++ internal/wslrun/terminal_linux.go | 46 +++++++++++++-------- internal/wslrun/terminal_linux_test.go | 55 ++++++++++++++++++++++++++ 7 files changed, 125 insertions(+), 33 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 72b1563..e05de8a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -377,7 +377,7 @@ wslproject -> hostenv, registry wslpathmap -> registry, wslproject wsldocker -> hostenv wslvolume -> hostenv, registry, wsldocker, wslproject -wslrun -> hostenv, lockfile, policy, registry, terminal, wsldocker, wslfs, wslpathmap, wslproject, wslshim, wslvolume +wslrun -> hostenv, lockfile, policy, registry, wsldocker, wslfs, wslpathmap, wslproject, wslshim, wslvolume selfupdate -> mutationlock, registry atomicio, dockervol, hostenv, mutationlock, terminal, toml -> (leaves) ``` diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 1d4686c..5a8a034 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -70,13 +70,15 @@ error wins over the tool status so truncated piped input is not reported as success. ContainerBin does not wait indefinitely for a terminal or pipe reader that remains blocked after the tool and output stream have both completed. -TTY mode is selected only when stdin accepts a real Linux termios query; -character-device mode alone is insufficient because `/dev/null` and `/dev/zero` -are not terminals. Stdout may be redirected. The native stdin terminal enters -raw mode, provides the initial size after start, and each `SIGWINCH` triggers a -fresh positive row/column resize from that same terminal. Docker's TTY stream is -unframed and is written to stdout; terminal state is restored on every return -path. +TTY mode is selected only when both stdin and stdout accept real Linux termios +queries; character-device mode alone is insufficient because `/dev/null` and +`/dev/zero` are not terminals. Redirecting either stream selects non-TTY +execution, matching the Windows frontend. The native stdin terminal enters raw +mode and provides the initial size after start when positive dimensions are +measurable. A zero-sized or temporarily unreadable terminal skips that resize, +and an unmeasurable `SIGWINCH` is dropped rather than terminating the running +tool. Docker's TTY stream is unframed and is written to stdout; terminal state +is restored on every return path. The runtime waits up to five seconds for the attach stream to drain after the Engine reports exit. Failure to drain is an infrastructure error rather than a diff --git a/docs/wsl.md b/docs/wsl.md index bc37d02..3f924a6 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -286,13 +286,15 @@ removal disabled, and verifies absence afterward. If daemon-side auto-remove wins the race between inspection and deletion, a DELETE 404 succeeds only after a fresh proof-bound inspection confirms absence. The runtime attaches before start, streams stdin with an explicit half-close, -decodes non-TTY stdout/stderr framing, selects TTY only when stdin accepts a -real termios query, uses raw terminal mode, applies the initial size from stdin, -consumes `SIGWINCH`, and forwards HUP, INT, QUIT, USR1, USR2, TERM, CONT, TSTP -and PIPE numerically to the exact owned container. Completion-race 404/409 -responses from resize/signal are deferred to the authoritative Engine wait. It -waits for the Engine exit status, drains output, restores the terminal and -performs proof-bound cleanup; the tool's `0..255` exit code passes through. +decodes non-TTY stdout/stderr framing, selects TTY only when both stdin and +stdout accept real termios queries, uses raw terminal mode, and applies a +positive initial size from stdin when measurable. Zero-sized or unreadable +dimensions skip that resize; an unmeasurable `SIGWINCH` is dropped rather than +terminating the tool. The runtime forwards HUP, INT, QUIT, USR1, USR2, TERM, +CONT, TSTP and PIPE numerically to the exact owned container. Completion-race +404/409 responses from resize/signal are deferred to the authoritative Engine +wait. It waits for the Engine exit status, drains output, restores the terminal +and performs proof-bound cleanup; the tool's `0..255` exit code passes through. Infrastructure or stream failure cancels the live wait, sends SIGKILL through the same proof-bound transport, waits for stop and then cleans up. Real WSL2 + Docker Desktop qualification remains mandatory before release support. diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index 6f04120..9b1686a 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -167,7 +167,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code waitCode, waitErr := deps.wait(runCtx, container.id) waitDone <- waitResult{code: waitCode, err: waitErr} }() - if plan.spec.TTY { + if plan.spec.TTY && term.height != 0 && term.width != 0 { if err := deps.resize(runCtx, container.id, term.height, term.width); err != nil && !containerCompletionRace(err) { return 0, fmt.Errorf("set initial native WSL container terminal size: %w", err) } diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index 5fde31c..8169a50 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -161,6 +161,27 @@ func TestExecuteToolPreservesFastTTYExitAcrossInitialResizeRace(t *testing.T) { } } +func TestExecuteToolSkipsUnknownInitialTTYSize(t *testing.T) { + stream := &fakeAttach{reader: bytes.NewReader([]byte("done")), writeDone: make(chan struct{})} + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps.prepareTerminal = func(bool) (terminalControl, error) { + return terminalControl{restore: func() error { return nil }}, nil + } + deps.resize = func(context.Context, string, uint16, uint16) error { + t.Fatal("unknown initial terminal size triggered a resize") + return nil + } + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", TTY: true, + }} + code, err := executeTool(context.Background(), plan, deps) + if err != nil || code != 23 || stdout.String() != "done" { + t.Fatalf("unknown-size TTY result code=%d stdout=%q err=%v", code, stdout.String(), err) + } +} + func TestExecuteToolPreservesExitAcrossQueuedControlEvents(t *testing.T) { stream := &fakeAttach{reader: bytes.NewReader([]byte("done")), writeDone: make(chan struct{})} var stdout, stderr bytes.Buffer diff --git a/internal/wslrun/terminal_linux.go b/internal/wslrun/terminal_linux.go index 551e08e..896ac18 100644 --- a/internal/wslrun/terminal_linux.go +++ b/internal/wslrun/terminal_linux.go @@ -32,11 +32,7 @@ func prepareHostTerminal(tty bool) (terminalControl, error) { if err := setTermios(stdinFD, raw); err != nil { return terminalControl{}, fmt.Errorf("enter native WSL raw terminal mode: %w", err) } - height, width, err := terminalSize(stdinFD) - if err != nil { - _ = setTermios(stdinFD, original) - return terminalControl{}, fmt.Errorf("read native WSL terminal size: %w", err) - } + height, width, _ := usableTerminalSize(stdinFD, terminalSize) var ( once sync.Once restoreErr error @@ -51,12 +47,19 @@ func prepareHostTerminal(tty bool) (terminalControl, error) { }, nil } -// interactiveHostTerminal requires a real Linux terminal on stdin. Character -// device mode alone is insufficient because /dev/null and /dev/zero also set -// os.ModeCharDevice but reject terminal ioctls. Stdout may be redirected; TTY -// sizing is taken from the controlling stdin terminal. +// interactiveHostTerminal requires real Linux terminals on stdin and stdout. +// Character-device mode alone is insufficient because /dev/null and /dev/zero +// also set os.ModeCharDevice but reject terminal ioctls. This matches the +// Windows frontend's rule that redirected output selects non-TTY execution. func interactiveHostTerminal() bool { - _, err := getTermios(os.Stdin.Fd()) + return interactiveTerminalPair(os.Stdin.Fd(), os.Stdout.Fd(), getTermios) +} + +func interactiveTerminalPair(stdinFD, stdoutFD uintptr, query func(uintptr) (syscall.Termios, error)) bool { + if _, err := query(stdinFD); err != nil { + return false + } + _, err := query(stdoutFD) return err == nil } @@ -97,11 +100,13 @@ func startHostEvents(tty bool) (<-chan hostEvent, func(), error) { } event := hostEvent{signal: int(number)} if number == syscall.SIGWINCH { - height, width, err := terminalSize(os.Stdin.Fd()) - event = hostEvent{resize: true, height: height, width: width} - if err != nil { - event = hostEvent{err: fmt.Errorf("read resized native WSL terminal: %w", err)} + height, width, ok := usableTerminalSize(os.Stdin.Fd(), terminalSize) + if !ok { + // Terminal dimensions are advisory. A transiently + // unreadable or zero-sized pty must not terminate the tool. + continue } + event = hostEvent{resize: true, height: height, width: width} } select { case events <- event: @@ -131,6 +136,16 @@ func setTermios(fd uintptr, value syscall.Termios) error { return nil } +type terminalSizeReader func(uintptr) (uint16, uint16, error) + +func usableTerminalSize(fd uintptr, read terminalSizeReader) (uint16, uint16, bool) { + height, width, err := read(fd) + if err != nil || height == 0 || width == 0 { + return 0, 0, false + } + return height, width, true +} + func terminalSize(fd uintptr) (uint16, uint16, error) { // Linux struct winsize is four consecutive unsigned shorts. Keep the // definition local rather than adding x/sys solely for one ioctl. @@ -144,8 +159,5 @@ func terminalSize(fd uintptr) (uint16, uint16, error) { if errno != 0 { return 0, 0, errno } - if size.Row == 0 || size.Col == 0 { - return 0, 0, errors.New("terminal reported zero rows or columns") - } return size.Row, size.Col, nil } diff --git a/internal/wslrun/terminal_linux_test.go b/internal/wslrun/terminal_linux_test.go index 095eb06..c73705d 100644 --- a/internal/wslrun/terminal_linux_test.go +++ b/internal/wslrun/terminal_linux_test.go @@ -3,12 +3,40 @@ package wslrun import ( + "errors" "os" "syscall" "testing" "time" ) +func TestUsableTerminalSize(t *testing.T) { + tests := []struct { + name string + height uint16 + width uint16 + err error + wantHeight uint16 + wantWidth uint16 + wantOK bool + }{ + {name: "measured", height: 24, width: 80, wantHeight: 24, wantWidth: 80, wantOK: true}, + {name: "zero rows", width: 80}, + {name: "zero columns", height: 24}, + {name: "unreadable", height: 24, width: 80, err: errors.New("temporary ioctl failure")}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + height, width, ok := usableTerminalSize(0, func(uintptr) (uint16, uint16, error) { + return test.height, test.width, test.err + }) + if height != test.wantHeight || width != test.wantWidth || ok != test.wantOK { + t.Fatalf("usableTerminalSize = (%d, %d, %t), want (%d, %d, %t)", height, width, ok, test.wantHeight, test.wantWidth, test.wantOK) + } + }) + } +} + func TestInteractiveHostTerminalRejectsNonTerminalCharacterDevice(t *testing.T) { device, err := os.Open("/dev/null") if err != nil { @@ -24,6 +52,33 @@ func TestInteractiveHostTerminalRejectsNonTerminalCharacterDevice(t *testing.T) } } +func TestInteractiveTerminalPairRequiresBothStreams(t *testing.T) { + tests := []struct { + name string + stdinTTY bool + stdoutTTY bool + want bool + }{ + {name: "both terminals", stdinTTY: true, stdoutTTY: true, want: true}, + {name: "redirected stdin", stdoutTTY: true}, + {name: "redirected stdout", stdinTTY: true}, + {name: "both redirected"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := interactiveTerminalPair(1, 2, func(fd uintptr) (syscall.Termios, error) { + if (fd == 1 && test.stdinTTY) || (fd == 2 && test.stdoutTTY) { + return syscall.Termios{}, nil + } + return syscall.Termios{}, syscall.ENOTTY + }) + if got != test.want { + t.Fatalf("interactiveTerminalPair = %t, want %t", got, test.want) + } + }) + } +} + func TestStartHostEventsInterceptsSIGPIPE(t *testing.T) { events, stop, err := startHostEvents(false) if err != nil { From 804a6cbc90f25d081895dd55d947a0eb690b22bb Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:44:46 +0100 Subject: [PATCH 6/8] Keep WSL signals intercepted through cleanup --- docs/wsl-process-contract.md | 6 +++++- internal/dockerrun/dockerrun.go | 7 +++++-- internal/wslrun/plan_test.go | 7 +++++++ internal/wslrun/runner.go | 9 ++++++--- internal/wslrun/runner_test.go | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index 5a8a034..d811270 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -107,7 +107,11 @@ while the container is running, ContainerBin cancels the live operations, sends SIGKILL through the proof-bound signal endpoint, waits for the retained container to stop, and then performs proof-bound cleanup. Any cleanup failure is joined to the original diagnostic. The top level maps infrastructure failures -to ContainerBin's documented exit code 120. +to ContainerBin's documented exit code 120. Catchable host signals remain +intercepted until that cleanup finishes, so their default disposition cannot +terminate the shim inside the bounded cleanup window and strand a retained +container. SIGKILL remains uncatchable and is covered by the activation gate's +orphan-reconciliation requirement. A failed start response is treated as transport-ambiguous: the Engine may have accepted the request before the connection failed. Cleanup therefore attempts diff --git a/internal/dockerrun/dockerrun.go b/internal/dockerrun/dockerrun.go index 2c26930..8110d0e 100644 --- a/internal/dockerrun/dockerrun.go +++ b/internal/dockerrun/dockerrun.go @@ -32,6 +32,10 @@ import ( // external /cb/mounts/N path, exactly as the design requires. const IsolatedRoot = "?:\\no-project" +// PythonBootstrap is exported within the internal tree so the native WSL +// frontend can pin exact command parity without depending on dockerrun at run time. +const PythonBootstrap = `if [ ! -x /venv/bin/python ]; then python -m venv /venv || exit $?; fi; if [ "$1" = "__CB_PIP__" ]; then shift; exec /venv/bin/python -m pip "$@"; else exec /venv/bin/python "$@"; fi` + type runContext struct { cwd string root string @@ -263,8 +267,7 @@ func buildDockerArgs(t registry.Tool, userArgs []string, ctx runContext, imageRe "-e", "PATH=/venv/bin:/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin", ) args = append(args, imageRef) - bootstrap := `if [ ! -x /venv/bin/python ]; then python -m venv /venv || exit $?; fi; if [ "$1" = "__CB_PIP__" ]; then shift; exec /venv/bin/python -m pip "$@"; else exec /venv/bin/python "$@"; fi` - args = append(args, "sh", "-c", bootstrap, "cb") + args = append(args, "sh", "-c", PythonBootstrap, "cb") if t.Role == "pip" { args = append(args, "__CB_PIP__") } diff --git a/internal/wslrun/plan_test.go b/internal/wslrun/plan_test.go index c8cc693..31f617f 100644 --- a/internal/wslrun/plan_test.go +++ b/internal/wslrun/plan_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/AviBackToBlack/container-bin/internal/dockerrun" "github.com/AviBackToBlack/container-bin/internal/hostenv" "github.com/AviBackToBlack/container-bin/internal/policy" "github.com/AviBackToBlack/container-bin/internal/registry" @@ -14,6 +15,12 @@ import ( const testNamespace = "wsl2-0123456789abcdef0123456789abcdef" +func TestPythonBootstrapMatchesWindowsFrontend(t *testing.T) { + if pythonBootstrap != dockerrun.PythonBootstrap { + t.Fatal("native WSL and Windows Python bootstrap commands diverged") + } +} + func TestBuildToolPlanWiresProjectMappingEnvironmentAndCommand(t *testing.T) { deps := testPlanDependencies(true) tool := registry.Tool{ diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index 9b1686a..ccda022 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -95,9 +95,6 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code ) defer func() { cancelRun() - if stopEvents != nil { - stopEvents() - } if stream != nil { if err := stream.Close(); retErr == nil && err != nil { retErr = fmt.Errorf("close native WSL attach stream: %w", err) @@ -138,6 +135,12 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code } } retErr = errors.Join(retErr, lifecycleErr) + if stopEvents != nil { + // Keep catchable host signals intercepted until proof-bound cleanup + // finishes. Restoring their default disposition earlier can terminate + // the shim inside the cleanup window and strand a retained container. + stopEvents() + } }() events, stop, err := deps.startEvents(plan.spec.TTY) diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index 8169a50..ddca116 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -229,6 +229,12 @@ func TestExecuteToolForceStopsOwnedContainerAfterStreamFailure(t *testing.T) { var stdout, stderr bytes.Buffer var calls []string deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + deps.startEvents = func(bool) (<-chan hostEvent, func(), error) { + calls = append(calls, "events") + events := make(chan hostEvent) + close(events) + return events, func() { calls = append(calls, "stop-events") }, nil + } deps.wait = func(ctx context.Context, _ string) (int, error) { if _, cleanup := ctx.Deadline(); cleanup { calls = append(calls, "cleanup-wait") @@ -255,6 +261,9 @@ func TestExecuteToolForceStopsOwnedContainerAfterStreamFailure(t *testing.T) { if !containsCall(calls, "cleanup-wait") || !containsCall(calls, "remove") { t.Fatalf("cleanup calls = %#v", calls) } + if removeAt, stopAt := callIndex(calls, "remove"), callIndex(calls, "stop-events"); removeAt < 0 || stopAt <= removeAt { + t.Fatalf("host events stopped before cleanup completed: %#v", calls) + } } func TestExecuteToolCleansUpAfterAmbiguousStartFailure(t *testing.T) { @@ -358,3 +367,12 @@ func containsCall(calls []string, want string) bool { } return false } + +func callIndex(calls []string, want string) int { + for index, call := range calls { + if call == want { + return index + } + } + return -1 +} From bef980133f867304b2d17a16a42c466c27238f3a Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 08:55:46 +0100 Subject: [PATCH 7/8] Preserve WSL stream cleanup diagnostics --- internal/wslrun/runner.go | 4 ++-- internal/wslrun/runner_test.go | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index ccda022..d3f2c22 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -96,8 +96,8 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code defer func() { cancelRun() if stream != nil { - if err := stream.Close(); retErr == nil && err != nil { - retErr = fmt.Errorf("close native WSL attach stream: %w", err) + if err := stream.Close(); err != nil { + retErr = errors.Join(retErr, fmt.Errorf("close native WSL attach stream: %w", err)) } } if term.restore != nil { diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index ddca116..17498cd 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -26,6 +26,7 @@ type fakeAttach struct { closed bool writeDone chan struct{} writeOnce sync.Once + closeErr error } func (s *fakeAttach) Read(p []byte) (int, error) { return s.reader.Read(p) } @@ -43,7 +44,7 @@ func (s *fakeAttach) CloseWrite() error { }) return nil } -func (s *fakeAttach) Close() error { s.closed = true; return nil } +func (s *fakeAttach) Close() error { s.closed = true; return s.closeErr } func (s *fakeAttach) Multiplexed() bool { return s.multiplexed } func TestExecuteToolStreamsMultiplexedIOAndPropagatesExitCode(t *testing.T) { @@ -225,7 +226,10 @@ func TestExecuteToolPreservesExitAcrossQueuedControlEvents(t *testing.T) { func TestExecuteToolForceStopsOwnedContainerAfterStreamFailure(t *testing.T) { malformed := rawFrame(1, "bad") malformed[1] = 1 - stream := &fakeAttach{reader: bytes.NewReader(malformed), multiplexed: true, writeDone: make(chan struct{})} + stream := &fakeAttach{ + reader: bytes.NewReader(malformed), multiplexed: true, + writeDone: make(chan struct{}), closeErr: errors.New("close failed"), + } var stdout, stderr bytes.Buffer var calls []string deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) @@ -252,7 +256,7 @@ func TestExecuteToolForceStopsOwnedContainerAfterStreamFailure(t *testing.T) { Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", RetainUntilCleanup: true, }} _, err := executeTool(context.Background(), plan, deps) - if err == nil || !strings.Contains(err.Error(), "raw-stream frame") { + if err == nil || !strings.Contains(err.Error(), "raw-stream frame") || !strings.Contains(err.Error(), "close native WSL attach stream: close failed") { t.Fatalf("stream failure = %v", err) } if !reflect.DeepEqual(signals, []int{9}) { From 42473ab6da523c9c0eb515ba6a6fad840cd7c828 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:27:27 +0100 Subject: [PATCH 8/8] Preserve WSL exit status after stdin peer close --- docs/wsl-process-contract.md | 11 ++-- docs/wsl.md | 12 ++-- internal/wslrun/input_error.go | 11 ++++ internal/wslrun/input_error_linux.go | 12 ++++ internal/wslrun/input_error_linux_test.go | 43 +++++++++++++ internal/wslrun/input_error_other.go | 7 +++ internal/wslrun/runner.go | 34 ++++++++--- internal/wslrun/runner_test.go | 74 ++++++++++++++++++++--- 8 files changed, 178 insertions(+), 26 deletions(-) create mode 100644 internal/wslrun/input_error.go create mode 100644 internal/wslrun/input_error_linux.go create mode 100644 internal/wslrun/input_error_linux_test.go create mode 100644 internal/wslrun/input_error_other.go diff --git a/docs/wsl-process-contract.md b/docs/wsl-process-contract.md index d811270..056e432 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -65,10 +65,13 @@ byte-for-byte and half-closed at EOF while output remains open. Docker's strict raw-stream framing is decoded into the caller's separate stdout and stderr; a truncated or malformed frame is an infrastructure failure. -If stdin copying has already completed with an error when the tool exits, that -error wins over the tool status so truncated piped input is not reported as -success. ContainerBin does not wait indefinitely for a terminal or pipe reader -that remains blocked after the tool and output stream have both completed. +If stdin's source reader has already completed with an error when the tool +exits, that error wins over the tool status so genuinely truncated piped input +is not reported as success. A closed attach sink (`EPIPE`, `ENOTCONN`, connection +reset or the standard closed-network sentinels) means the tool no longer accepts +input and defers to the authoritative Engine wait status instead. ContainerBin +does not wait indefinitely for a terminal or pipe reader that remains blocked +after the tool and output stream have both completed. TTY mode is selected only when both stdin and stdout accept real Linux termios queries; character-device mode alone is insufficient because `/dev/null` and diff --git a/docs/wsl.md b/docs/wsl.md index 3f924a6..ab6d6c9 100644 --- a/docs/wsl.md +++ b/docs/wsl.md @@ -290,11 +290,13 @@ decodes non-TTY stdout/stderr framing, selects TTY only when both stdin and stdout accept real termios queries, uses raw terminal mode, and applies a positive initial size from stdin when measurable. Zero-sized or unreadable dimensions skip that resize; an unmeasurable `SIGWINCH` is dropped rather than -terminating the tool. The runtime forwards HUP, INT, QUIT, USR1, USR2, TERM, -CONT, TSTP and PIPE numerically to the exact owned container. Completion-race -404/409 responses from resize/signal are deferred to the authoritative Engine -wait. It waits for the Engine exit status, drains output, restores the terminal -and performs proof-bound cleanup; the tool's `0..255` exit code passes through. +terminating the tool. A source-side stdin read failure remains fatal, while a +closed attach sink after the tool stops defers to the authoritative Engine wait +status. The runtime forwards HUP, INT, QUIT, USR1, USR2, TERM, CONT, TSTP and +PIPE numerically to the exact owned container. Completion-race 404/409 responses +from resize/signal are likewise deferred to the Engine wait. It waits for the +Engine exit status, drains output, restores the terminal and performs proof-bound +cleanup; the tool's `0..255` exit code passes through. Infrastructure or stream failure cancels the live wait, sends SIGKILL through the same proof-bound transport, waits for stop and then cleans up. Real WSL2 + Docker Desktop qualification remains mandatory before release support. diff --git a/internal/wslrun/input_error.go b/internal/wslrun/input_error.go new file mode 100644 index 0000000..7d1da47 --- /dev/null +++ b/internal/wslrun/input_error.go @@ -0,0 +1,11 @@ +package wslrun + +import ( + "errors" + "io" + "net" +) + +func inputPeerClosed(err error) bool { + return errors.Is(err, io.ErrClosedPipe) || errors.Is(err, net.ErrClosed) || platformInputPeerClosed(err) +} diff --git a/internal/wslrun/input_error_linux.go b/internal/wslrun/input_error_linux.go new file mode 100644 index 0000000..53a2d9e --- /dev/null +++ b/internal/wslrun/input_error_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +package wslrun + +import ( + "errors" + "syscall" +) + +func platformInputPeerClosed(err error) bool { + return errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ENOTCONN) || errors.Is(err, syscall.ECONNRESET) +} diff --git a/internal/wslrun/input_error_linux_test.go b/internal/wslrun/input_error_linux_test.go new file mode 100644 index 0000000..c87f40b --- /dev/null +++ b/internal/wslrun/input_error_linux_test.go @@ -0,0 +1,43 @@ +//go:build linux + +package wslrun + +import ( + "bytes" + "errors" + "io" + "net" + "syscall" + "testing" +) + +func TestCopyToolInputTreatsLinuxPeerCloseAsCompletion(t *testing.T) { + tests := []struct { + name string + writeErr error + closeWriteErr error + }{ + {name: "write EPIPE", writeErr: linuxNetworkError(syscall.EPIPE)}, + {name: "write reset", writeErr: linuxNetworkError(syscall.ECONNRESET)}, + {name: "half-close ENOTCONN", closeWriteErr: linuxNetworkError(syscall.ENOTCONN)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stream := &fakeAttach{ + reader: bytes.NewReader(nil), writeDone: make(chan struct{}), + writeErr: test.writeErr, closeWriteErr: test.closeWriteErr, + } + err := copyToolInput(stream, bytes.NewBufferString("input")) + if !errors.Is(err, io.ErrClosedPipe) { + t.Fatalf("copyToolInput() error = %v, want closed-pipe completion", err) + } + if !stream.closedWrite { + t.Fatal("copyToolInput() did not attempt the stdin half-close") + } + }) + } +} + +func linuxNetworkError(err error) error { + return &net.OpError{Op: "write", Net: "unix", Err: err} +} diff --git a/internal/wslrun/input_error_other.go b/internal/wslrun/input_error_other.go new file mode 100644 index 0000000..16c4e8f --- /dev/null +++ b/internal/wslrun/input_error_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package wslrun + +func platformInputPeerClosed(error) bool { + return false +} diff --git a/internal/wslrun/runner.go b/internal/wslrun/runner.go index d3f2c22..2ab1baf 100644 --- a/internal/wslrun/runner.go +++ b/internal/wslrun/runner.go @@ -188,13 +188,7 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code }() inputDone := make(chan error, 1) go func() { - _, copyErr := io.Copy(stream, deps.stdin) - closeErr := stream.CloseWrite() - if copyErr != nil { - inputDone <- copyErr - return - } - inputDone <- closeErr + inputDone <- copyToolInput(stream, deps.stdin) }() var outputResult *error for { @@ -258,6 +252,32 @@ func executeTool(ctx context.Context, plan toolPlan, deps runDependencies) (code } } +type inputSinkWriter struct { + destination io.Writer +} + +func (w inputSinkWriter) Write(p []byte) (int, error) { + written, err := w.destination.Write(p) + // Only normalize errors observed at the attach sink. A source-side read + // failure returned by io.Copy bypasses this wrapper and remains fatal. + if inputPeerClosed(err) { + return written, io.ErrClosedPipe + } + return written, err +} + +func copyToolInput(stream attachStream, source io.Reader) error { + _, copyErr := io.Copy(inputSinkWriter{destination: stream}, source) + closeErr := stream.CloseWrite() + if copyErr != nil { + return copyErr + } + if inputPeerClosed(closeErr) { + return io.ErrClosedPipe + } + return closeErr +} + func containerCompletionRace(err error) bool { var apiErr *wsldocker.APIError if !errors.As(err, &apiErr) { diff --git a/internal/wslrun/runner_test.go b/internal/wslrun/runner_test.go index 17498cd..acb3e9e 100644 --- a/internal/wslrun/runner_test.go +++ b/internal/wslrun/runner_test.go @@ -6,6 +6,7 @@ import ( "encoding/binary" "errors" "io" + "net" "net/http" "reflect" "strings" @@ -18,21 +19,26 @@ import ( ) type fakeAttach struct { - mu sync.Mutex - reader *bytes.Reader - writes bytes.Buffer - multiplexed bool - closedWrite bool - closed bool - writeDone chan struct{} - writeOnce sync.Once - closeErr error + mu sync.Mutex + reader *bytes.Reader + writes bytes.Buffer + multiplexed bool + closedWrite bool + closed bool + writeDone chan struct{} + writeOnce sync.Once + closeErr error + writeErr error + closeWriteErr error } func (s *fakeAttach) Read(p []byte) (int, error) { return s.reader.Read(p) } func (s *fakeAttach) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() + if s.writeErr != nil { + return 0, s.writeErr + } return s.writes.Write(p) } func (s *fakeAttach) CloseWrite() error { @@ -42,7 +48,7 @@ func (s *fakeAttach) CloseWrite() error { close(s.writeDone) } }) - return nil + return s.closeWriteErr } func (s *fakeAttach) Close() error { s.closed = true; return s.closeErr } func (s *fakeAttach) Multiplexed() bool { return s.multiplexed } @@ -304,6 +310,46 @@ func TestExecuteToolReportsCompletedInputFailureBeforeSuccessfulExit(t *testing. } } +func TestExecuteToolPreservesExitAcrossClosedInputSink(t *testing.T) { + stream := &fakeAttach{ + reader: bytes.NewReader(rawFrame(1, "done")), multiplexed: true, writeDone: make(chan struct{}), + writeErr: &net.OpError{Op: "write", Net: "unix", Err: net.ErrClosed}, + } + var stdout, stderr bytes.Buffer + var calls []string + deps := successfulRunDependencies(t, stream, &stdout, &stderr, &calls) + plan := toolPlan{spec: wsldocker.ContainerCreateSpec{ + Tool: "demo", Namespace: testNamespace, Image: "demo:1", WorkingDirectory: "/root", + }} + code, err := executeTool(context.Background(), plan, deps) + if err != nil || code != 23 || stdout.String() != "done" { + t.Fatalf("closed-input result code=%d stdout=%q err=%v", code, stdout.String(), err) + } +} + +func TestCopyToolInputPreservesSourceReadFailure(t *testing.T) { + sourceErr := errors.New("source read failed") + stream := &fakeAttach{reader: bytes.NewReader(nil), writeDone: make(chan struct{})} + err := copyToolInput(stream, errorReader{err: sourceErr}) + if !errors.Is(err, sourceErr) { + t.Fatalf("copyToolInput() error = %v, want source failure", err) + } + if !stream.closedWrite { + t.Fatal("copyToolInput() did not half-close stdin after the source failure") + } +} + +func TestCopyToolInputPreservesUnexpectedSinkFailure(t *testing.T) { + sinkErr := errors.New("unexpected sink failure") + stream := &fakeAttach{ + reader: bytes.NewReader(nil), writeDone: make(chan struct{}), writeErr: sinkErr, + } + err := copyToolInput(stream, strings.NewReader("input")) + if !errors.Is(err, sinkErr) { + t.Fatalf("copyToolInput() error = %v, want unexpected sink failure", err) + } +} + func TestFinishToolResultDoesNotWaitForBlockedInput(t *testing.T) { inputDone := make(chan error) code, err := finishToolResult(23, inputDone) @@ -380,3 +426,11 @@ func callIndex(calls []string, want string) int { } return -1 } + +type errorReader struct { + err error +} + +func (r errorReader) Read([]byte) (int, error) { + return 0, r.err +}