diff --git a/README.md b/README.md index 8297caf..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 | **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 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 | @@ -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. 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 @@ -1110,11 +1110,13 @@ 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 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). - Container startup adds latency compared to native binaries (typically diff --git a/docs/architecture.md b/docs/architecture.md index 5a62fa3..e05de8a 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, 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,23 @@ 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 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 +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. +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 3c69cc8..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** | 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 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,21 +344,19 @@ 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 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 - 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..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 | **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 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,17 +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 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. 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. 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, @@ -726,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 WSL2 runtime/Docker wiring 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 4439b05..3f324e9 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -93,17 +93,18 @@ readable, and dangerous to let others edit. 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. + 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. 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. @@ -150,22 +151,31 @@ 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. + 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/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..056e432 100644 --- a/docs/wsl-process-contract.md +++ b/docs/wsl-process-contract.md @@ -1,103 +1,128 @@ # 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. 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). -## 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. + +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 +`/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 +silent loss of trailing output. + +## Signals, failures and exit status + +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 +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. 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 +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..ab6d6c9 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 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 supported frontend. +- Native Windows is the currently qualified release frontend. The native WSL2 + 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 @@ -29,9 +30,10 @@ 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 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; @@ -65,9 +67,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 +113,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 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; @@ -161,7 +165,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 +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. -Runtime execution is still gated. +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 @@ -259,30 +264,42 @@ 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, 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. 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. ## Native WSL volume identity and control lifecycle @@ -309,29 +326,31 @@ 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. 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. +`cb state`/`cb gc` and backup/restore remain separate native management work. + +## Remaining before the v2 WSL support claim + +The ordinary managed tool path is implemented behind the host gate. Activation +and release qualification still require all of the following: + +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; +3. 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. +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 +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/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/hostenv/hostenv.go b/internal/hostenv/hostenv.go index 370d446..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 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 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()) } @@ -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 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 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..853fbf6 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 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/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..a4542b9 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 ( @@ -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: GATED (runtime wiring and WSL E2E are not enabled)") 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..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: 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: 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/frontend.go b/internal/wslrun/frontend.go new file mode 100644 index 0000000..e34d3e8 --- /dev/null +++ b/internal/wslrun/frontend.go @@ -0,0 +1,114 @@ +package wslrun + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "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) + absPath func(string) (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.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() + 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 = deps.absPath(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..990f067 --- /dev/null +++ b/internal/wslrun/frontend_test.go @@ -0,0 +1,106 @@ +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 }, + 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 }, + 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 }, + 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 }, + 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/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/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..31f617f --- /dev/null +++ b/internal/wslrun/plan_test.go @@ -0,0 +1,160 @@ +package wslrun + +import ( + "reflect" + "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" + "github.com/AviBackToBlack/container-bin/internal/wslproject" + "github.com/AviBackToBlack/container-bin/internal/wslvolume" +) + +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{ + 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..b15cbd8 --- /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/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, + absPath: filepath.Abs, + evalSymlinks: filepath.EvalSymlinks, + getwd: os.Getwd, + interactive: interactiveHostTerminal, + 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..2ab1baf --- /dev/null +++ b/internal/wslrun/runner.go @@ -0,0 +1,303 @@ +package wslrun + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "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 stream != nil { + if err := stream.Close(); err != nil { + retErr = errors.Join(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, 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)) + } + } + 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) + 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) + } + waitDone := make(chan waitResult, 1) + go func() { + waitCode, waitErr := deps.wait(runCtx, container.id) + waitDone <- waitResult{code: waitCode, err: waitErr} + }() + 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) + } + } + + 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() { + inputDone <- copyToolInput(stream, deps.stdin) + }() + 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 finishToolResult(result.code, inputDone) + } + 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 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(): + 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 && !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 && !containerCompletionRace(err) { + 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()) + } + } +} + +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) { + 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 + } + 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 new file mode 100644 index 0000000..acb3e9e --- /dev/null +++ b/internal/wslrun/runner_test.go @@ -0,0 +1,436 @@ +package wslrun + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "net" + "net/http" + "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 + 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 { + s.closedWrite = true + s.writeOnce.Do(func() { + if s.writeDone != nil { + close(s.writeDone) + } + }) + return s.closeWriteErr +} +func (s *fakeAttach) Close() error { s.closed = true; return s.closeErr } +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 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 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 + 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 + 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) + 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") + 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") || !strings.Contains(err.Error(), "close native WSL attach stream: close failed") { + 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) + } + 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) { + 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") || !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) { + t.Fatalf("cleanup calls = %#v, want %#v", got, want) + } +} + +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 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) + 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{ + 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 +} + +func callIndex(calls []string, want string) int { + for index, call := range calls { + if call == want { + return index + } + } + return -1 +} + +type errorReader struct { + err error +} + +func (r errorReader) Read([]byte) (int, error) { + return 0, r.err +} diff --git a/internal/wslrun/terminal_linux.go b/internal/wslrun/terminal_linux.go new file mode 100644 index 0000000..896ac18 --- /dev/null +++ b/internal/wslrun/terminal_linux.go @@ -0,0 +1,163 @@ +//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, _ := usableTerminalSize(stdinFD, terminalSize) + 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 +} + +// 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 { + 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 +} + +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) + } + 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, 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: + 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 +} + +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. + 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 + } + return size.Row, size.Col, nil +} diff --git a/internal/wslrun/terminal_linux_test.go b/internal/wslrun/terminal_linux_test.go new file mode 100644 index 0000000..c73705d --- /dev/null +++ b/internal/wslrun/terminal_linux_test.go @@ -0,0 +1,99 @@ +//go:build linux + +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 { + 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 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 { + 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") + } +} diff --git a/main.go b/main.go index f0721df..11d05ec 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 and cb wsl ... are enabled. The managed-tool runtime is wired but + activation awaits orphan reconciliation and real Docker Desktop qualification. + 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") }