diff --git a/.env.example b/.env.example index c1255ca..1f332f8 100644 --- a/.env.example +++ b/.env.example @@ -146,6 +146,14 @@ GIT_USER_EMAIL= # Detection is automatic if the skillset lives at WORKSPACE_PATH/skillset. # SKILLSET_CONTAINER_PATH= +# ── cli_utils (standalone commands from a mounted checkout) ────────── +# If a cli_utils repo is mounted, the entrypoint symlinks its bin/ commands +# into ~/.local/bin on every start, so they survive container recreate and +# resolve in non-interactive shells too (docker exec, agent tool shells). +# Detection is automatic at WORKSPACE_PATH/cli_utils (or one level below). +# CLI_UTILS_CONTAINER_PATH= +# CLI_UTILS_LINK=0 # disable the linking entirely + # ── Locale ─────────────────────────────────────────────────────────── # LANG=sv_SE.UTF-8 # LANGUAGE=sv_SE:sv diff --git a/CHANGELOG.md b/CHANGELOG.md index 9349d4e..b97eff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,75 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). --- +## Unreleased + +**Shell state that the writable layer eats on every recreate now gets rebuilt at +start.** Two additions to `entrypoint-user.sh`, both idempotent, both silent +no-ops when the thing they wire up is absent. + +**`cli_utils` commands are linked onto `PATH`.** If a `cli_utils` checkout is +mounted, every executable in its `bin/` is symlinked into `~/.local/bin` at +container start — `git-status-all`, `git-pull-all`, `devbox-sanity`, +`pi-devbox-sanity`, `pi-session-repair`, `docker-clean`, `vpn-status`. Detection: +`CLI_UTILS_CONTAINER_PATH` → `/workspace/cli_utils` → `$HOME/cli_utils` → +`/workspace/*/cli_utils`; `CLI_UTILS_LINK=0` disables it. + +The reason this is an *image* concern and not the user's problem to re-solve: on a +host, `cli_utils/install.sh` puts those commands on `PATH` by symlinking them into +`~/.local/bin`, which is persistent there — and **ephemeral here**. Same installer, +same repo, opposite durability, so the fix died on every `--force-recreate` and the +next session was back to typing `/workspace/cli_utils/bin/git-status-all`. Running +`install.sh` *inside* a container is the trap rather than the fix: it re-creates +the same disposable state. + +**Symlinks rather than a `PATH` edit in an rc file, deliberately.** `~/.local/bin` +is already ahead of `/usr/local/bin` in `ENV PATH`, so links resolve in +**non-interactive** shells too — `docker exec git-status-all`, agent tool +shells, scripts. An rc-file `PATH` edit cannot reach those: `~/.bashrc` returns +early when the shell is not interactive. Measured on tor-ms22 2026-08-27, +`command -v git-status-all` failed in a non-interactive shell while succeeding in +an interactive one, from exactly that asymmetry. Guards, because `~/.local/bin` is +shared with other tooling: a real file is never clobbered, a symlink pointing +somewhere else is never stolen, our own links are refreshed, and links into a +`cli_utils/bin` whose target vanished are pruned — a dangling link on `PATH` +reports "No such file or directory" and reads as a broken container rather than a +removed script. + +**A per-device boot hook: `~/.config/devbox-shell/init.sh`.** If the host provides +one, it runs once at start with output to `~/.pi/agent/devbox-init.log`. That +directory is the host-owned bind-mount already sourced into every interactive +shell by `/etc/skel-devbox/.bash_aliases`, so this is its boot-time twin — the +same ownership and the same persistence, but running *before any shell*, which is +what non-interactive fixups (symlinks, directories, one-off migrations) need. **It +introduces no new trust boundary**: that path is already arbitrary code from the +same owner; only *when* it runs is new. Invoked as `bash `, never sourced, +and its exit status is ignored — a hook must not be able to mutate the +entrypoint's own shell state or stop a container from starting. + +With the hook in place, the next "can this run on every recreate?" question needs +no image change at all — which is the point, given what the next paragraph costs. + +**This moves the base hash.** `base-decide` folds `cat entrypoint.sh +entrypoint-user.sh` into it, so this change forces the ~40-minute base rebuild at +the next tag whether or not anything else in the base moved. It is a rider, not a +reason to tag. + +**How it was validated, since CI cannot.** `docker-publish.yml` runs only on +`push: tags: v*`, and `lint.yml` runs `actionlint` over workflow `run:` steps — +neither one executes `entrypoint-user.sh`. So both sections were extracted and run +against fixtures in a throwaway `$HOME` before commit: real file not clobbered, +foreign symlink respected, stale link pruned, new command picked up, second run +byte-identical, `CLI_UTILS_LINK=0` honoured, and "no `cli_utils` anywhere" a silent +`exit 0`. Then run for real in a live v1.8.10 container, after which +`command -v git-status-all` resolved in a *non-interactive* shell. No +`smoke-test.sh` assertion was added on purpose: the positive path needs a +`/workspace` mount that smoke does not have, and asserting it there would repeat +the v1.8.0 mistake of a smoke assertion written against a stage that does not +exist at run time. `workflow_dispatch` with `smoke_only` remains the way to +exercise this against `HEAD` before a tag. + +--- + ## v1.8.10 — 2026-08-27 **This tag exists to deploy a fix and a safety net that are currently running on diff --git a/README.md b/README.md index 5b13c8d..97472bb 100644 --- a/README.md +++ b/README.md @@ -538,6 +538,35 @@ to refresh. Anything not on a volume is on the writable layer and is lost on container recreate. +### Rebuilding ephemeral shell state at start + +Two entrypoint steps put back the kind of state that the writable layer eats, so a +recreate does not cost you a manual re-install: + +- **`cli_utils` commands.** If a `cli_utils` checkout is mounted, every + executable in its `bin/` is symlinked into `~/.local/bin` on start, so + `git-status-all` and friends are on `PATH` without a path prefix. Detection: + `CLI_UTILS_CONTAINER_PATH` → `/workspace/cli_utils` → `$HOME/cli_utils` → + `/workspace/*/cli_utils`. Set `CLI_UTILS_LINK=0` to disable. Existing real files + in `~/.local/bin` and symlinks pointing elsewhere are left alone, so a + deliberate override still wins; links whose target disappeared are pruned. + Do **not** run a host installer's `install.sh` inside the container to achieve + this — it writes to the ephemeral home and dies on the next recreate. +- **A per-device boot hook.** If `~/.config/devbox-shell/init.sh` exists it is run + once at start (`bash`, never sourced, exit status ignored), with output in + `~/.pi/agent/devbox-init.log`. `~/.config/devbox-shell/` is the host-owned + bind-mount whose `bash_aliases` is already sourced into every interactive shell, + so a hook there persists across recreates with no image change. Use it for + fixups that must exist *before any shell* — symlinks, directories, one-off + migrations. + +The distinction that decides which mechanism you want: `~/.local/bin` is on `ENV +PATH`, so symlinks there work in **non-interactive** shells too (`docker exec +`, agent tool shells, scripts). A `PATH` edit in `bash_aliases` reaches only +*interactive* shells, because `~/.bashrc` returns early when non-interactive — +which is also why shell **functions** (fzf helpers and the like) can only come +from the sourced file, never from a symlink. + ## MemPalace integration MemPalace is installed in the base image and pre-warmed with the diff --git a/entrypoint-user.sh b/entrypoint-user.sh index 3683d54..a55c84b 100755 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -188,6 +188,111 @@ if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then fi fi +# ── cli_utils: link workspace bin/ commands onto PATH ──────────────── +# Standalone commands from a mounted cli_utils checkout (git-status-all, +# git-pull-all, devbox-sanity, pi-session-repair, ...) live in /bin. On a +# host they reach PATH via cli_utils' own install.sh, whose install_bin step +# symlinks them into ~/.local/bin — but that home is on the container's WRITABLE +# LAYER, so every recreate loses them and the human is back to typing +# /workspace/cli_utils/bin/git-status-all. This is the container equivalent of +# that install step, re-run at every start. +# +# WHY SYMLINKS RATHER THAN A PATH EDIT IN AN rc FILE: ~/.local/bin is already +# ahead of /usr/local/bin in ENV PATH (Dockerfile.base), so links here resolve in +# NON-interactive shells too — `docker exec git-status-all`, agent tool +# shells, scripts. An rc-file PATH edit cannot reach those, because ~/.bashrc +# returns early when the shell is not interactive. Measured 2026-08-27 on +# tor-ms22: `command -v git-status-all` failed in a non-interactive shell while +# working in an interactive one, from exactly that asymmetry. +# +# Detection order (first hit wins): +# 1. CLI_UTILS_CONTAINER_PATH explicit, for non-standard layouts +# 2. /workspace/cli_utils repo directly in the workspace root +# 3. $HOME/cli_utils dedicated mount +# 4. /workspace/*/cli_utils workspace root holds several repo groups +# CLI_UTILS_LINK=0 disables. Absent repo = silent no-op, which is the common +# case for anyone who does not use cli_utils. +if [ "${CLI_UTILS_LINK:-1}" != "0" ]; then + CLI_UTILS_BIN="" + if [ -n "${CLI_UTILS_CONTAINER_PATH:-}" ] && [ -d "${CLI_UTILS_CONTAINER_PATH}/bin" ]; then + CLI_UTILS_BIN="${CLI_UTILS_CONTAINER_PATH}/bin" + elif [ -d /workspace/cli_utils/bin ]; then + CLI_UTILS_BIN=/workspace/cli_utils/bin + elif [ -d "$HOME/cli_utils/bin" ]; then + CLI_UTILS_BIN="$HOME/cli_utils/bin" + else + # `if` bodies, not `&&` chains: under `set -e` a loop whose LAST command is a + # false test exits non-zero and would abort the entrypoint. With no match the + # glob stays literal, so that is the normal case on any machine without this + # repo — i.e. the bug would have been "container will not start", not "links + # missing". + for _cu in /workspace/*/cli_utils/bin; do + if [ -d "$_cu" ]; then + CLI_UTILS_BIN="$_cu" + break + fi + done + unset _cu + fi + + if [ -n "$CLI_UTILS_BIN" ]; then + mkdir -p "$HOME/.local/bin" 2>/dev/null || true + # Never clobber a real file, and never steal a link that points elsewhere: a + # deliberate user override in ~/.local/bin must win, and silently shadowing + # an image-provided command is worse than the missing command. + for _f in "$CLI_UTILS_BIN"/*; do + if [ ! -f "$_f" ] || [ ! -x "$_f" ]; then + continue + fi + _link="$HOME/.local/bin/$(basename "$_f")" + if [ -e "$_link" ] && [ ! -L "$_link" ]; then + continue + fi + if [ -L "$_link" ]; then + case "$(readlink "$_link")" in + "$CLI_UTILS_BIN"/*) ;; + *) continue ;; + esac + fi + ln -sf "$_f" "$_link" 2>/dev/null || true + done + # Prune links we own whose target vanished (command renamed, repo moved), + # mirroring the skillset deploy's --prune-stale. A dangling link on PATH + # reports "No such file or directory" for a command that simply no longer + # exists, which reads as a broken container rather than a removed script. + for _link in "$HOME/.local/bin"/*; do + [ -L "$_link" ] || continue + case "$(readlink "$_link")" in + */cli_utils/bin/*) [ -e "$_link" ] || rm -f "$_link" ;; + esac + done + unset _f _link + fi + unset CLI_UTILS_BIN +fi + +# ── Per-device boot hook ───────────────────────────────────────────── +# Runs ~/.config/devbox-shell/init.sh if the host provides one. That directory is +# the host-owned, bind-mounted shell-sharing dir (see "Volumes and persistence"), +# so a hook placed there survives every recreate WITHOUT an image change — the +# boot-time twin of the interactive bridge in /etc/skel-devbox/.bash_aliases, +# which sources ~/.config/devbox-shell/bash_aliases for every interactive shell. +# +# NO NEW TRUST BOUNDARY: that same directory is already sourced into every +# interactive shell, i.e. it is already arbitrary code from the same owner. What +# is new is only WHEN it runs — once at start, before any shell — which is what +# non-interactive fixups (symlinks, dirs, one-off migrations) need. +# +# Deliberately `bash `, not `.` — a hook must not be able to mutate this +# entrypoint's own shell state, and its exit status must not matter. Output goes +# to a log rather than the container's start output, so a chatty hook cannot +# masquerade as a startup error. +if [ -r "$HOME/.config/devbox-shell/init.sh" ]; then + mkdir -p "$HOME/.pi/agent" 2>/dev/null || true + bash "$HOME/.config/devbox-shell/init.sh" \ + >"$HOME/.pi/agent/devbox-init.log" 2>&1 || true +fi + # ── Git config defaults ────────────────────────────────────────────── if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then git config --global user.name "$GIT_USER_NAME"