diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a91447..b7eb9088 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.2] - 2026-08-20 + +### Fixed + +- **The tools devlaunch installs no longer edit the container's own pixi + manifest.** `gh`, `claude` and `zellij` go into `~/.devlaunch/pixi` rather than + `~/.pixi`, because `pixi global install` is not only an install: it is an edit + to `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a + container already has an owner. Writing there cost something in both + directions. `pixi global sync` removes every environment the manifest does not + list, so a dotfiles apply that rewrote the manifest and synced **uninstalled** + the zellij devlaunch had just installed — invisibly, and the next launch + reinstalled it, forever. And the manifest is not always a file: a devcontainer + is free to symlink it onto a tracked file inside the checkout, and + `kinisi_ros`'s does, so the append landed in the work tree and every + `git status` in the workspace came up dirty. An install that dirties the tree + it was pointed at is the launch damaging the work it exists to serve, and + `dl` launches arbitrary repos. A home devlaunch created makes both + unrepresentable rather than handled: nothing syncs that manifest, and no repo + state can sit beneath that path. + + Not `~/.local/share/devlaunch/pixi`, which is the conventional path and the + wrong one here — containers bind-mount `~/.cache`, `~/.config` and + `~/.local/share` straight from the host, so a prefix tree under one would be + shared by every container on the machine and written into your own home; + prefixes are baked with absolute paths, which is prefix-dev/pixi#5476, the + hazard the shared package cache already keeps `PIXI_HOME` away from. + `PIXI_HOME` is set only for devlaunch's own install scripts and never exported + into the login profile, so **your own `pixi global install` in a workspace + still goes to your own `~/.pixi`**; only the bin directories go on `PATH`. + + Existing containers are untouched: their tools are already on the login `PATH`, + so the presence check passes and nothing is reinstalled. The devcontainer + feature's installer still writes `~/.pixi` deliberately — it runs at image + build time, where that path is the image's own and there is no checkout to + dirty. + ## [0.2.1] - 2026-08-20 ### Changed diff --git a/README.md b/README.md index 01bfa7e1..c0cbef99 100644 --- a/README.md +++ b/README.md @@ -1290,6 +1290,40 @@ every launch after it. `1777` is what `/var/tmp` carries at the other end of the same mount, and it makes the same trade: every uid can write, and the sticky bit means none of them can unlink another's entries. +### Where the tools themselves land + +The cache above is shared; the *environments* devlaunch installs into are not, +and they are not the container's `~/.pixi` either. `gh`, `claude` and `zellij` go +into **`~/.devlaunch/pixi`**, a pixi home of devlaunch's own, because +`pixi global install` is not only an install — it is an edit to +`$PIXI_HOME/manifests/pixi-global.toml`, a declarative file that in a container +already has an owner. Writing there made devlaunch a second author, and cost +something in both directions: + +- `pixi global sync` removes every environment the manifest does not list, so a + dotfiles apply that rewrites the manifest and syncs **uninstalls** the zellij + devlaunch just installed — and the next launch reinstalls it, forever. +- The manifest is not always a file. A devcontainer is free to symlink + `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the checkout, + and one does; the append then landed in the work tree and every `git status` + in the workspace came up dirty. + +Neither is expressible against a home devlaunch created: nothing syncs that +manifest, and no repo state can sit under that path. It costs a duplicate +extracted prefix in the one case where a tool is installed but unreachable from +a login shell — disk only, since `PIXI_HOME` does not move the download cache — +and that is the case where the old behaviour reinstalled on every launch anyway. + +Not `~/.local/share/devlaunch/pixi`, which is the conventional path and the wrong +one here: containers bind-mount `~/.cache`, `~/.config` and `~/.local/share` +straight from the host, so a prefix tree under one of them would be shared by +every container on the machine and written into your own home — pixi#5476 again, +the hazard the cache mount is careful to keep `PIXI_HOME` away from. + +`PIXI_HOME` is set only for devlaunch's own install scripts, never exported into +the login profile, so **your own `pixi global install` in a workspace still goes +to your own `~/.pixi`**. Only the bin directory goes on `PATH`. + ### Existing containers, and what a recreate is for **A mount lands only when a container is created.** devpod re-applies diff --git a/devlaunch/tools.py b/devlaunch/tools.py index 098667b8..bcc49ad8 100644 --- a/devlaunch/tools.py +++ b/devlaunch/tools.py @@ -22,7 +22,9 @@ holds, which turns the slowest part of a cold launch (minutes of in-container downloads) into a local copy. Only when the host has nothing to lend, or the lent binaries do not run there (a different arch or libc), does the old -network path run: bootstrap pixi, `pixi global install` each tool. +network path run: bootstrap pixi, `pixi global install` each tool -- into a +pixi home of devlaunch's own rather than the container's `~/.pixi`, for the +reasons PIXI_HOME_TARGET gives. What "lacks them" means is the probe's job, and it has three answers rather than two, because a `claude` on PATH is not necessarily a `claude` worth @@ -114,6 +116,49 @@ # that reads someone else's artifact is only correct until they change it. PROFILE_MARK = "# devlaunch:" +# The pixi home devlaunch installs its own tools into, and never `~/.pixi`. +# +# `pixi global install` is not just an install: it is an edit to +# `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file whose owner then +# syncs it. `~/.pixi` is somebody else's -- an image's, or a dotfiles repo's -- +# and writing there made devlaunch a second author of a file with one owner, +# which cost something in both directions: +# +# 1. `pixi global sync` removes every env the manifest does not list, so a +# dotfiles apply that rewrites the manifest and syncs *uninstalls* the +# zellij this module just installed -- on a schedule nothing here can see, +# and the next launch reinstalls it, forever. +# 2. The manifest is not always a file. kinisi_ros's devcontainer symlinks +# `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the +# checkout (its `.devcontainer/on_create.sh`), so the append landed in the +# user's work tree and every `git status` in the workspace came up dirty. +# Nothing about that is one repo's quirk: dl launches arbitrary repos, and +# an install that dirties the tree it was pointed at is the launch damaging +# the work it exists to serve. +# +# A home of devlaunch's own makes both unrepresentable rather than handled. +# Nothing syncs this manifest, so what is installed here stays installed; and +# every path under it is one devlaunch created, so no repo state can be beneath +# it. What it costs is a duplicate prefix in the case where a tool is installed +# but unreachable from a login shell -- disk only, since PIXI_HOME does not move +# the package cache (`PIXI_CACHE_TARGET` still shares the downloads) -- and that +# case is the one where the old behaviour reinstalled on every launch anyway. +# +# `$HOME/.devlaunch/pixi` and *not* `$HOME/.local/share/devlaunch/pixi`, which is +# the conventional answer and the wrong one here. Containers bind-mount the XDG +# trees -- `~/.cache`, `~/.config`, `~/.local/share` -- straight from the host, +# so a prefix tree under `.local/share` would be shared by every container on the +# machine and written into the host's own home. Prefixes are baked with absolute +# paths and two syncs sharing one prefix tree is prefix-dev/pixi#5476: exactly +# the hazard PIXI_CACHE_TARGET refuses to let PIXI_HOME near. A dotted directory +# in `$HOME` is nobody's convention to mount, and `transfer_script` already +# stages under `$HOME/.devlaunch-lend`. +# +# Deliberately *not* exported into the login profile. Only these scripts set it, +# so a user's own `pixi global install` still goes to their `~/.pixi` -- which is +# the separation this exists to keep. Only the bin directories go on PATH. +PIXI_HOME_TARGET = "$HOME/.devlaunch/pixi" + @dataclass(frozen=True) class Tool: @@ -293,8 +338,9 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: Idempotent and cheap on the common path: every tool already on PATH is skipped, so a workspace that has been provisioned before does nothing but answer. It runs under a login shell (see provision_tools), which is what puts - an earlier run's ~/.pixi/bin on PATH -- checked from a non-login shell every - tool would look missing and be reinstalled on every launch. + an earlier run's bin directory (PIXI_HOME_TARGET) on PATH -- checked from a + non-login shell every tool would look missing and be reinstalled on every + launch. Exits 0 unless an install actually failed, so "nothing to do" and "all installs worked" are the same answer to the caller. @@ -308,7 +354,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: """ all_present = _all_present(tools) installs = "\n".join(_install_line(tool) for tool in tools) - # The trampoline pixi writes into ~/.pixi/bin does not work for packages + # The trampoline pixi writes into its bin directory does not work for packages # that ship a shell script, which is why the env's own bin directory is # added too -- the same workaround .devcontainer/claude-code/install.sh # carries, for the same package. @@ -321,10 +367,10 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: # (since the check above is `command -v`) reinstalled from scratch # on every single launch. _profile_resolution(), - _profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"), + _profile_prepend(f'export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', on_failure="failed=1"), _profile_prepend( - '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && ' - 'export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"', + f'[ -d "{PIXI_HOME_TARGET}/envs/claude-shim/bin" ] && ' + f'export PATH="{PIXI_HOME_TARGET}/envs/claude-shim/bin:$PATH"', on_failure="failed=1", ), ] @@ -342,6 +388,7 @@ def provision_script(tools: Sequence[Tool] = REQUIRED_TOOLS) -> str: # Everything already there: leave without touching pixi, the # profile, or the network. Every launch after the first takes this. f"if {all_present}; then exit 0; fi", + _pixi_home_export(), _pixi_bootstrap(), installs, profile_lines, @@ -363,9 +410,9 @@ def zellij_script() -> str: Idempotent and nearly free on the common path: a `zellij` already on PATH exits before pixi, the profile or the network are touched, and that is the answer on every launch after the first. The check works because this runs - under the pass's login shell, which is what puts an earlier run's - ~/.pixi/bin on PATH -- from a non-login shell an installed zellij looks - missing and would be reinstalled on every single launch. + under the pass's login shell, which is what puts an earlier run's bin + directory (PIXI_HOME_TARGET) on PATH -- from a non-login shell an installed + zellij looks missing and would be reinstalled on every single launch. The PATH prepend is written here rather than relied on from elsewhere, because the container this most often lands in has not had it written. A @@ -395,10 +442,11 @@ def zellij_script() -> str: # Already there: nothing else in this script may run. The commonest # answer by far, and the reason a stage on every pass is affordable. f"if command -v {shlex.quote(ZELLIJ_TOOL.command)} >/dev/null 2>&1; then exit 0; fi", + _pixi_home_export(), _pixi_bootstrap(), _install_line(ZELLIJ_TOOL), _profile_resolution(), - _profile_prepend('export PATH="$HOME/.pixi/bin:$PATH"', on_failure="failed=1"), + _profile_prepend(f'export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', on_failure="failed=1"), 'exit "$failed"', ] ) @@ -721,6 +769,24 @@ def _read_outcome(name: str, status: Optional[str]) -> StageOutcome: return StageNotReached(name=name) +def _pixi_home_export() -> str: + """Point pixi at devlaunch's own home (PIXI_HOME_TARGET) for what follows. + + One line at the top of every script here that runs pixi, and it has to cover + more than the installs: pixi's own installer honours the variable too + (`PIXI_HOME="${PIXI_HOME:-$HOME/.pixi}"` in its install.sh), so exporting it + once puts the binary and the environments in the same place instead of + bootstrapping pixi into a home its installs do not use. + + Exported rather than passed per command, so nothing pixi does on its own + behalf can miss it -- and set flatly rather than defaulted to an existing + value, because a PIXI_HOME already in the container's environment is the + image's answer to where *its* globals live, which is the answer this is here + not to use. + """ + return f'export PIXI_HOME="{PIXI_HOME_TARGET}"' + + def _pixi_bootstrap() -> str: """Install pixi if the image has none, since every tool here comes from it. @@ -734,7 +800,7 @@ def _pixi_bootstrap() -> str: "if ! command -v pixi >/dev/null 2>&1; then", ' echo "devlaunch: installing pixi"', " curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true", - ' export PATH="$HOME/.pixi/bin:$PATH"', + f' export PATH="{PIXI_HOME_TARGET}/bin:$PATH"', "fi", ] ) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7ff99f43..577ca70f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -13,7 +13,7 @@ dependencies = [ [[package]] name = "aid" -version = "0.2.1" +version = "0.2.2" dependencies = [ "dl", "serde_json", @@ -438,7 +438,7 @@ dependencies = [ [[package]] name = "devlaunch-core" -version = "0.2.1" +version = "0.2.2" dependencies = [ "devlaunch-runner", "devlaunch-test-support", @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "devlaunch-runner" -version = "0.2.1" +version = "0.2.2" dependencies = [ "libc", "tempfile", @@ -464,7 +464,7 @@ dependencies = [ [[package]] name = "devlaunch-test-support" -version = "0.2.1" +version = "0.2.2" dependencies = [ "devlaunch-runner", "serde_json", @@ -510,7 +510,7 @@ dependencies = [ [[package]] name = "dl" -version = "0.2.1" +version = "0.2.2" dependencies = [ "assert_cmd", "clap", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 39c61fb9..50e760c1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -11,7 +11,7 @@ members = [ # The single source of the version (docs/rust-rewrite-plan.md: cutover ships # 0.1.0, version read from Cargo.toml). [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2024" license = "MIT" repository = "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/blooop/devlaunch" diff --git a/rust/devlaunch-core/src/flows/provision.rs b/rust/devlaunch-core/src/flows/provision.rs index 002d295b..d213b788 100644 --- a/rust/devlaunch-core/src/flows/provision.rs +++ b/rust/devlaunch-core/src/flows/provision.rs @@ -412,17 +412,63 @@ fn install_line(tool: &Tool) -> String { const PIXI_BOOTSTRAP: &str = r#"if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi"#; -/// The `~/.pixi/bin` prepend, which two scripts and the feature installer write. -const PIXI_BIN_LINE: &str = r#"export PATH="$HOME/.pixi/bin:$PATH""#; - -/// The trampoline pixi writes into `~/.pixi/bin` does not work for packages that -/// ship a shell script, which is why the env's own bin directory is added too — -/// the same workaround `.devcontainer/claude-code/install.sh` carries, for the +/// Point pixi at a home of devlaunch's own (`$HOME/.devlaunch/pixi`), never `~/.pixi`. +/// +/// `pixi global install` is not only an install: it is an edit to +/// `$PIXI_HOME/manifests/pixi-global.toml`, a *declarative* file that in a +/// container already has an owner — an image's, or a dotfiles repo's. Writing +/// there made devlaunch a second author of a file with one owner, and it cost +/// something in both directions: +/// +/// 1. `pixi global sync` removes every env the manifest does not list, so a +/// dotfiles apply that rewrites the manifest and syncs *uninstalls* the zellij +/// the setup pass just installed — on a schedule nothing here can see, and the +/// next launch reinstalls it, forever. +/// 2. The manifest is not always a file. kinisi_ros's devcontainer symlinks +/// `~/.pixi/manifests/pixi-global.toml` onto a tracked file inside the checkout, +/// so the append landed in the user's work tree and every `git status` in the +/// workspace came up dirty. That is not one repo's quirk: dl launches arbitrary +/// repos, and an install that dirties the tree it was pointed at is the launch +/// damaging the work it exists to serve. +/// +/// A home of devlaunch's own makes both unrepresentable rather than handled: +/// nothing syncs this manifest, and every path under it is one devlaunch created. +/// +/// `$HOME/.devlaunch/pixi` and *not* `$HOME/.local/share/devlaunch/pixi`, which is +/// the conventional answer and the wrong one here — containers bind-mount +/// `~/.cache`, `~/.config` and `~/.local/share` straight from the host, so a prefix +/// tree under one would be shared by every container on the machine and written +/// into the host's own home. Prefixes are baked with absolute paths and two syncs +/// sharing one tree is prefix-dev/pixi#5476: the hazard the shared package cache +/// refuses to let PIXI_HOME near. +/// +/// Set on every script here that runs pixi, ahead of everything else it does. +/// +/// It has to come before the bootstrap and not merely before the installs: pixi's +/// own installer honours the variable too (`${PIXI_HOME:-$HOME/.pixi}` in its +/// install.sh), so the other order would leave the binary in one home and every +/// env it installs in another — the one shape of this that fails silently, since +/// both halves work alone. +/// +/// Set flatly rather than defaulted to an existing value: a PIXI_HOME already in +/// the container's environment is the image's answer to where *its* globals live, +/// which is the answer this is here not to use. Never written to the login +/// profile, so a user's own `pixi global install` still goes to their `~/.pixi`. +const PIXI_HOME_EXPORT: &str = r#"export PIXI_HOME="$HOME/.devlaunch/pixi""#; + +/// The bin prepend, which both scripts write. The feature installer still names +/// `~/.pixi/bin`: it is baked into an image at build time, where that path is the +/// image's own and there is no checkout to dirty. +const PIXI_BIN_LINE: &str = r#"export PATH="$HOME/.devlaunch/pixi/bin:$PATH""#; + +/// The trampoline pixi writes into its bin directory does not work for packages +/// that ship a shell script, which is why the env's own bin directory is added too +/// — the same workaround `.devcontainer/claude-code/install.sh` carries, for the /// same package. -const CLAUDE_SHIM_BIN_LINE: &str = r#"[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH""#; +const CLAUDE_SHIM_BIN_LINE: &str = r#"[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH""#; /// The `~/.local/bin` prepend a lend writes, and it goes in *last* on purpose: /// the container this lend exists for already has a shim earlier on PATH, so the @@ -475,6 +521,7 @@ pub(crate) fn provision_script(tools: &[Tool]) -> String { // Everything already there: leave without touching pixi, the profile, or // the network. Every launch after the first takes this. format!("if {}; then exit 0; fi", all_present(tools)), + PIXI_HOME_EXPORT.to_owned(), PIXI_BOOTSTRAP.to_owned(), installs, profile_lines, @@ -515,6 +562,7 @@ pub(crate) fn zellij_script() -> String { "if command -v {} >/dev/null 2>&1; then exit 0; fi", quote(ZELLIJ_TOOL.command) ), + PIXI_HOME_EXPORT.to_owned(), PIXI_BOOTSTRAP.to_owned(), install_line(&ZELLIJ_TOOL), profile_resolution("$HOME"), @@ -1761,10 +1809,11 @@ mod tests { exec >&2 failed=0 if command -v gh >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v gh >/dev/null 2>&1; then echo "devlaunch: installing gh" @@ -1778,18 +1827,19 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE" || failed=1 -grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 190e825e206b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 190e825e206b' '[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 exit "$failed""#; const PYTHON_PROVISION_SCRIPT_JQ: &str = r#"set -u exec >&2 failed=0 if command -v jq >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v jq >/dev/null 2>&1; then echo "devlaunch: installing jq" @@ -1799,17 +1849,18 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE" || failed=1 -grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 190e825e206b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 190e825e206b' '[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE" || failed=1 exit "$failed""#; const PYTHON_ZELLIJ_SCRIPT: &str = r#"set -u failed=0 if command -v zellij >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v zellij >/dev/null 2>&1; then echo "devlaunch: installing zellij" @@ -1819,7 +1870,7 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE" || failed=1 +grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE" || failed=1 exit "$failed""#; const PYTHON_PROBE_SCRIPT: &str = r#"set -u @@ -1863,10 +1914,11 @@ fi"#; const PYTHON_ZELLIJ_STAGE: &str = r#"if bash -c 'set -u failed=0 if command -v zellij >/dev/null 2>&1; then exit 0; fi +export PIXI_HOME="$HOME/.devlaunch/pixi" if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi if ! command -v zellij >/dev/null 2>&1; then echo "devlaunch: installing zellij" @@ -1876,7 +1928,7 @@ if [ -f "$HOME/.bash_profile" ]; then PROFILE="$HOME/.bash_profile" elif [ -f "$HOME/.bash_login" ]; then PROFILE="$HOME/.bash_login" else PROFILE="$HOME/.profile" fi -grep -qxF '"'"'# devlaunch: 6b593c3a6327'"'"' "$PROFILE" 2>/dev/null || printf '"'"'%s\n'"'"' '"'"'# devlaunch: 6b593c3a6327'"'"' '"'"'export PATH="$HOME/.pixi/bin:$PATH"'"'"' >> "$PROFILE" || failed=1 +grep -qxF '"'"'# devlaunch: 87ccd356540b'"'"' "$PROFILE" 2>/dev/null || printf '"'"'%s\n'"'"' '"'"'# devlaunch: 87ccd356540b'"'"' '"'"'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"'"'"' >> "$PROFILE" || failed=1 exit "$failed"' >&2; then echo "devlaunch-probe stage zellij ok" else @@ -1893,16 +1945,16 @@ elif [ -f "$TARGET_HOME/.bash_login" ]; then PROFILE="$TARGET_HOME/.bash_login" else PROFILE="$TARGET_HOME/.profile" fi"#; - const PYTHON_PREPEND_PIXI_BIN: &str = r#"grep -qxF '# devlaunch: 6b593c3a6327' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 6b593c3a6327' 'export PATH="$HOME/.pixi/bin:$PATH"' >> "$PROFILE""#; + const PYTHON_PREPEND_PIXI_BIN: &str = r#"grep -qxF '# devlaunch: 87ccd356540b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 87ccd356540b' 'export PATH="$HOME/.devlaunch/pixi/bin:$PATH"' >> "$PROFILE""#; - const PYTHON_PREPEND_SHIM_BIN: &str = r#"grep -qxF '# devlaunch: 12897b113bea' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 12897b113bea' '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE""#; + const PYTHON_PREPEND_SHIM_BIN: &str = r#"grep -qxF '# devlaunch: 190e825e206b' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 190e825e206b' '[ -d "$HOME/.devlaunch/pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.devlaunch/pixi/envs/claude-shim/bin:$PATH"' >> "$PROFILE""#; const PYTHON_PREPEND_LOCAL_BIN: &str = r#"grep -qxF '# devlaunch: 63c662ba0560' "$PROFILE" 2>/dev/null || printf '%s\n' '# devlaunch: 63c662ba0560' 'export PATH="$HOME/.local/bin:$PATH"' >> "$PROFILE""#; const PYTHON_PIXI_BOOTSTRAP: &str = r#"if ! command -v pixi >/dev/null 2>&1; then echo "devlaunch: installing pixi" curl -fsSL https://pixi.sh/install.sh | bash >/dev/null 2>&1 || true - export PATH="$HOME/.pixi/bin:$PATH" + export PATH="$HOME/.devlaunch/pixi/bin:$PATH" fi"#; const PYTHON_INSTALL_CLAUDE: &str = r#"if ! command -v claude >/dev/null 2>&1; then @@ -2462,9 +2514,15 @@ fi /// script, because a test that derives what to expect from the thing under /// test agrees with it however wrong it is. fn prepended(self) -> &'static str { + // No catch-all: the two runtime writers and the build-time feature + // installer no longer agree, and a `_` arm is what would let a third + // writer inherit whichever answer happened to be the default. match self { + Writer::Provision | Writer::Zellij => ".devlaunch/pixi/bin", Writer::Transfer => ".local/bin", - _ => ".pixi/bin", + // The feature is baked into an image at build time, where ~/.pixi + // is the image's own and there is no checkout to dirty. + Writer::Feature => ".pixi/bin", } } } @@ -2670,9 +2728,16 @@ fi // The marks are content hashes a shell script cannot derive for itself, so // the devcontainer feature pastes them; a different derivation here would // silently cost every workspace one duplicate PATH entry per line. - assert_eq!(mark_digest(PIXI_BIN_LINE), "6b593c3a6327"); - assert_eq!(mark_digest(CLAUDE_SHIM_BIN_LINE), "12897b113bea"); + assert_eq!(mark_digest(PIXI_BIN_LINE), "87ccd356540b"); + assert_eq!(mark_digest(CLAUDE_SHIM_BIN_LINE), "190e825e206b"); assert_eq!(mark_digest(LOCAL_BIN_LINE), "63c662ba0560"); + // The feature installer's own two, which are the marks actually pasted + // into `.devcontainer/claude-code/install.sh`. Different lines from the + // runtime writers' since those moved to `~/.devlaunch/pixi`, and so + // different marks -- which is the dedupe working rather than failing: two + // pixi homes are two directories a login shell genuinely needs. + assert_eq!(mark_digest(FEATURE_PIXI_BIN_LINE), "6b593c3a6327"); + assert_eq!(mark_digest(FEATURE_CLAUDE_SHIM_BIN_LINE), "12897b113bea"); } // ======================================================================= @@ -2753,7 +2818,7 @@ fi fn it_puts_the_pixi_bin_directory_on_the_login_path() { // Without this the next launch reinstalls everything, forever. let script = provision_script(&REQUIRED_TOOLS); - assert!(script.contains(".pixi/bin")); + assert!(script.contains(".devlaunch/pixi/bin")); assert!(script.contains(".profile")); } @@ -2947,6 +3012,18 @@ fi } } + /// The two PATH lines `.devcontainer/claude-code/install.sh` writes, which are + /// deliberately *not* [`PIXI_BIN_LINE`] and [`CLAUDE_SHIM_BIN_LINE`] any more. + /// + /// The feature runs at image build time and installs into the image's own + /// `~/.pixi`, where there is no checkout to dirty and no other owner to race -- + /// so it has no reason to move, and moving it would invalidate the devcontainer + /// prebuild for nothing. The runtime writers moved; this did not, and the two + /// therefore append two different lines under two different marks. Both land, + /// which is correct: they name two real directories. + const FEATURE_PIXI_BIN_LINE: &str = r#"export PATH="$HOME/.pixi/bin:$PATH""#; + const FEATURE_CLAUDE_SHIM_BIN_LINE: &str = r#"[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH""#; + #[test] fn the_feature_installer_writes_the_very_fragments_devlaunch_renders() { // The installer's two profile edits are `profile_prepend`'s own output, @@ -2956,7 +3033,7 @@ fi // line changes, and a drifted mark quietly costs a workspace one duplicate // PATH entry per line. let installer = feature_installer(); - for line in [PIXI_BIN_LINE, CLAUDE_SHIM_BIN_LINE] { + for line in [FEATURE_PIXI_BIN_LINE, FEATURE_CLAUDE_SHIM_BIN_LINE] { let fragment = profile_prepend(line, None); assert!( installer.contains(&fragment), diff --git a/rust/devlaunch-test-support/src/lib.rs b/rust/devlaunch-test-support/src/lib.rs index 34c3796f..e36947f3 100644 --- a/rust/devlaunch-test-support/src/lib.rs +++ b/rust/devlaunch-test-support/src/lib.rs @@ -32,6 +32,6 @@ pub use response::Response; mod tests { #[test] fn versioned_with_the_workspace() { - assert_eq!(env!("CARGO_PKG_VERSION"), "0.2.1"); + assert_eq!(env!("CARGO_PKG_VERSION"), "0.2.2"); } } diff --git a/test/unit/test_tools.py b/test/unit/test_tools.py index 5e73dd48..37c42dcf 100644 --- a/test/unit/test_tools.py +++ b/test/unit/test_tools.py @@ -4,6 +4,7 @@ import logging import os import pathlib +import re import shlex import shutil import subprocess @@ -119,7 +120,7 @@ def test_a_failed_install_is_reported_through_the_exit_status(self): def test_it_puts_the_pixi_bin_directory_on_the_login_path(self): """Without this the next launch reinstalls everything, forever.""" script = provision_script() - assert ".pixi/bin" in script + assert f"{tools.PIXI_HOME_TARGET}/bin" in script assert ".profile" in script def test_it_writes_to_the_profile_bash_actually_reads(self): @@ -190,10 +191,13 @@ class TestProfileGuards: # script, because a test that derives what to expect from the thing under # test agrees with it however wrong it is. PREPENDED = { - "provision": ".pixi/bin", + "provision": ".devlaunch/pixi/bin", "transfer": ".local/bin", + # The feature installer is baked into an image at build time, where + # ~/.pixi is the image's own and there is no checkout to dirty, so it + # is the one writer that still names it. "feature": ".pixi/bin", - "zellij": ".pixi/bin", + "zellij": ".devlaunch/pixi/bin", } # Every shape a container's home can have when a writer arrives. bash @@ -1606,9 +1610,16 @@ def _sandbox(tmp_path, *, has_zellij=False, pixi_exit=None, has_pixi_install=Fal log = tmp_path / "calls.log" quoted_log = shlex.quote(str(log)) - def fake(name, exit_code, noise=""): + env_log = tmp_path / "env.log" + + def fake(name, exit_code, noise="", record_env=False): path = sysbin / name body = f'echo "{name} $*" >> {quoted_log}\n' + if record_env: + # A log of its own rather than the call log, so the existing + # "pixi was never run" assertions keep asking about calls and + # not about a variable whose value contains the word. + body += f'echo "PIXI_HOME=${{PIXI_HOME-unset}}" >> {shlex.quote(str(env_log))}\n' if noise: body += f"echo {shlex.quote(noise)}\n" path.write_text(f"#!/bin/sh\n{body}exit {exit_code}\n", encoding="utf-8") @@ -1619,7 +1630,7 @@ def fake(name, exit_code, noise=""): if pixi_exit is not None: # Noise on *stdout*, because that is the stream the pass's protocol # shares and the one a stage may never speak on. - fake("pixi", pixi_exit, noise="PIXI-NOISE") + fake("pixi", pixi_exit, noise="PIXI-NOISE", record_env=True) # A curl that cannot reach anything, which is what an offline image is. fake("curl", 1) if has_pixi_install: @@ -1697,7 +1708,7 @@ def test_a_cold_container_gets_it_and_the_next_shell_can_find_it(self, tmp_path) home, result, calls = self._run(tmp_path, pixi_exit=0) assert self._zellij_outcome(result.stdout) == tools.StageOk(name=tools.ZELLIJ_STAGE) assert "pixi global install zellij" in calls - assert 'export PATH="$HOME/.pixi/bin:$PATH"' in (home / ".profile").read_text( + assert f'export PATH="{tools.PIXI_HOME_TARGET}/bin:$PATH"' in (home / ".profile").read_text( encoding="utf-8" ) @@ -1752,3 +1763,179 @@ def test_the_tools_opt_out_asks_for_no_zellij(self, monkeypatch, tmp_path): assert tools.HOSTNAME_STAGE in names _, _, calls = self._run(tmp_path, pixi_exit=0) assert "pixi" not in calls + + def test_the_install_never_touches_the_containers_own_pixi_home(self, tmp_path): + """The property, run rather than read off the script text. + + `pixi global install` edits `$PIXI_HOME/manifests/pixi-global.toml`, a + declarative file, and in a container `~/.pixi` is somebody else's: an + image's, or a dotfiles repo's. Two owners cost this both ways -- a + `pixi global sync` against a manifest that does not declare zellij + *uninstalls* it, and kinisi_ros's devcontainer symlinks that very + manifest onto a tracked file in the checkout, so the append dirtied the + user's work tree. A home of devlaunch's own is what makes neither + expressible. + """ + home, _, _ = self._run(tmp_path, pixi_exit=0) + recorded = (tmp_path / "env.log").read_text(encoding="utf-8") + assert f"PIXI_HOME={home}/.devlaunch/pixi" in recorded + assert not (home / ".pixi").exists() + + def test_the_home_is_set_before_anything_pixi_runs(self): + """Ordering, because the bootstrap is a consumer and not just a prelude. + + pixi's own installer honours PIXI_HOME (`${PIXI_HOME:-$HOME/.pixi}`), so + exporting it after the bootstrap would leave the binary in one home and + every environment it installs in another -- which is the one shape of + this change that fails silently, since both halves work alone. + """ + script = tools.zellij_script() + assert script.index("PIXI_HOME=") < script.index("command -v pixi") + assert script.index("PIXI_HOME=") < script.index("pixi global install") + + +class TestPixiHome: + """Where devlaunch's own tools live, asked of every script that installs one.""" + + def test_no_script_devlaunch_runs_in_a_container_names_the_shared_home(self): + """`~/.pixi` may appear in this module's reasoning, never in its output. + + Asked of the rendered scripts rather than the source, so the docstrings + that explain *why* the path is avoided cannot fail the test that it is. + """ + for name, script in ( + ("provision", provision_script()), + ("zellij", tools.zellij_script()), + ): + assert "$HOME/.pixi" not in script, name + + def test_the_home_is_not_under_a_bind_mounted_xdg_tree(self): + """The reason it is not `~/.local/share/devlaunch/pixi`. + + Containers mount `~/.cache`, `~/.config` and `~/.local/share` straight + from the host, so a prefix tree under one of them is shared by every + container on the machine and written into the host's own home -- and + prefixes are baked with absolute paths, which is prefix-dev/pixi#5476, + the hazard dl.PIXI_CACHE_TARGET already refuses to let PIXI_HOME near. + """ + target = tools.PIXI_HOME_TARGET + assert target.startswith("$HOME/") + for mounted in (".local/share", ".config", ".cache"): + assert mounted not in target + + def test_the_package_cache_is_still_shared(self): + """PIXI_HOME moves the manifest and the prefixes, not the downloads. + + Verified against pixi 0.77: `PIXI_HOME=x pixi info` reports the global + bin and manifest under `x` and leaves the cache dir alone. If that ever + stopped being true, every container would go back to downloading its own + packages -- the whole point of the shared mount -- so the two variables + being independent is pinned here rather than assumed. + """ + from devlaunch import dl + + assert dl.PIXI_CACHE_TARGET not in tools.PIXI_HOME_TARGET + assert tools.PIXI_HOME_TARGET not in dl.PIXI_CACHE_TARGET + + +# The Rust port's golden strings, whose own header calls them "what +# devlaunch/tools.py renders, byte for byte". Until this class existed, that was +# a claim in a comment: the goldens are hardcoded, so they were compared against +# the Rust renderer and never against the Python one they are named for. +RUST_PROVISION = ( + pathlib.Path(__file__).resolve().parents[2] / "rust/devlaunch-core/src/flows/provision.rs" +) + + +class TestRustGoldensAreWhatPythonRenders: + """The two implementations of these scripts, held to one text. + + `dl` ships as the Rust build; this tree is the reference implementation + beside it. Both render the same shell into someone else's container, and + until this class the only thing keeping them in step was that a person + editing one remembered the other. That is the arrangement this module + refuses everywhere else -- `PROFILE_MARK` exists because "a guard that reads + someone else's artifact is only correct until they change it", and + `_profile_prepend` derives its marks so a collision is unrepresentable + rather than asserted. + + The hole was not hypothetical. Moving the tools into their own pixi home had + to be carried into both trees by hand, and the Rust side's *internal* tests + caught a divergence the Python side's did not (the feature installer's PATH + line, which deliberately did not move). Had the edit gone the other way -- + Python only -- nothing at all would have failed: these goldens would have + kept agreeing with the Rust renderer while both drifted from the module they + are named after. `rust/tools/parity_cases.txt` does not cover this; it + compares `dl`'s observable behaviour, and these scripts are payload strings + inside a `devpod ssh --command`. + + So the comparison is made here, in the tree that owns the definition, and + the second test is what stops a new golden from being added outside it. + """ + + # Each golden, and the call in this module that must produce it. Written out + # rather than discovered, because a mapping derived from the thing under test + # agrees with it however wrong it is. + @staticmethod + def _renderings(tmp_path): + # pylint: disable=protected-access + claude = next(tool for tool in REQUIRED_TOOLS if tool.command == "claude") + stages = {stage.name: stage for stage in tools.setup_stages("myws")} + pixi_bin = f'export PATH="{tools.PIXI_HOME_TARGET}/bin:$PATH"' + shim_bin = ( + f'[ -d "{tools.PIXI_HOME_TARGET}/envs/claude-shim/bin" ] && ' + f'export PATH="{tools.PIXI_HOME_TARGET}/envs/claude-shim/bin:$PATH"' + ) + return { + "PYTHON_PROVISION_SCRIPT": provision_script(), + "PYTHON_PROVISION_SCRIPT_JQ": provision_script([Tool(command="jq", package="jq")]), + "PYTHON_ZELLIJ_SCRIPT": tools.zellij_script(), + "PYTHON_PROBE_SCRIPT": tools.probe_script(), + "PYTHON_TRANSFER_SCRIPT": tools.transfer_script(fake_payload(tmp_path)), + "PYTHON_HOSTNAME_STAGE": tools._stage_snippet(stages[tools.HOSTNAME_STAGE]), + "PYTHON_ZELLIJ_STAGE": tools._stage_snippet(stages[tools.ZELLIJ_STAGE]), + "PYTHON_PROFILE_RESOLUTION_HOME": tools._profile_resolution("$HOME"), + "PYTHON_PROFILE_RESOLUTION_TARGET": tools._profile_resolution("$TARGET_HOME"), + "PYTHON_PREPEND_PIXI_BIN": tools._profile_prepend(pixi_bin), + "PYTHON_PREPEND_SHIM_BIN": tools._profile_prepend(shim_bin), + "PYTHON_PREPEND_LOCAL_BIN": tools._profile_prepend( + 'export PATH="$HOME/.local/bin:$PATH"' + ), + "PYTHON_PIXI_BOOTSTRAP": tools._pixi_bootstrap(), + "PYTHON_INSTALL_CLAUDE": tools._install_line(claude), + } + + @staticmethod + def _goldens(): + """Every `PYTHON_*` golden in the Rust test module, by name. + + Cut out of the source text rather than run, so this needs no cargo and + no Rust toolchain -- it is a `pytest` that fails in the tree where the + definition lives, which is where the edit that would break it is made. + """ + text = RUST_PROVISION.read_text(encoding="utf-8") + found = dict(re.findall(r'const (PYTHON_[A-Z_]+): &str = r#"(.*?)"#;', text, re.S)) + assert found, f"no goldens found in {RUST_PROVISION} -- did the raw-string form change?" + return found + + def test_every_golden_is_byte_for_byte_what_this_module_renders(self, tmp_path): + """The claim in the goldens' own header, checked instead of trusted.""" + renderings = self._renderings(tmp_path) + for name, golden in sorted(self._goldens().items()): + assert name in renderings, f"{name} has no rendering to compare against" + assert golden == renderings[name], ( + f"{name} in {RUST_PROVISION.name} is no longer what this module renders; " + "regenerate it from the Python call rather than editing it by hand -- " + "the marks in it are content hashes, and a retyped digit is a duplicate " + "PATH entry in every workspace forever" + ) + + def test_no_golden_escapes_the_comparison(self, tmp_path): + """A golden added without a rendering here would be checked by nothing. + + The test above only compares the goldens it has a mapping for, so this is + the half that makes the coverage total: a new `PYTHON_*` const fails here + until someone says which call in this module is supposed to produce it. + """ + unmapped = sorted(set(self._goldens()) - set(self._renderings(tmp_path))) + assert not unmapped, f"goldens with no Python rendering to check them: {unmapped}"