entrypoint: put back the shell state a recreate eats
cli_utils' install.sh reaches PATH by symlinking bin/ into ~/.local/bin. That is persistent on a host and ephemeral in a container, so the same installer produced opposite durability and every --force-recreate sent the human back to typing /workspace/cli_utils/bin/git-status-all. Re-link at start instead, and add a per-device boot hook so the next question of this shape needs no image change at all. 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, agent tool shells, scripts). An rc-file PATH edit cannot reach those because ~/.bashrc returns early when not interactive — measured, that asymmetry is exactly why `command -v git-status-all` failed in one shell and worked in another on the same box. Shell FUNCTIONS remain the sourced file's job; a symlink cannot carry them. Guards, because ~/.local/bin is shared: a real file is never clobbered, a symlink pointing elsewhere is never stolen, ours are refreshed, and links into a cli_utils/bin whose target vanished are pruned — a dangling link on PATH reads as a broken container rather than a removed script. The hook (~/.config/devbox-shell/init.sh) adds no trust boundary: that dir is already sourced into every interactive shell by the baked bash_aliases, so it is already arbitrary code from the same owner. Only WHEN it runs is new. bash <file>, never sourced, exit status ignored, output to a log. Caught before commit, and the reason the loops use `if` bodies instead of `&&` chains: under `set -euo pipefail` a for-loop whose last command is a false test exits non-zero, and with no match the /workspace/*/cli_utils glob stays literal — so the first draft would have failed to START a container on every machine that does not have this repo, rather than merely skipping the links. Re-tested with set -e in place: no-cli_utils/empty-HOME no-op, guards, idempotence, CLI_UTILS_LINK=0, a hook that exits 7, and a hook that tries to mutate CLI_UTILS_BIN — all exit 0 with intact state. Not covered by CI: docker-publish.yml runs only on tags and lint.yml lints workflow run: steps, so neither executes this file. Validated by extracting both sections and running them against fixtures, then for real in a live v1.8.10 container. No smoke assertion added on purpose — the positive path needs a /workspace mount smoke does not have, and asserting it there would repeat the v1.8.0 mistake of a smoke check written against a stage that does not exist at run time. Moves the base hash (base-decide folds `cat entrypoint.sh entrypoint-user.sh`), so this rides along with the next tag's ~40-minute base rebuild rather than justifying a tag of its own.
This commit is contained in:
@@ -146,6 +146,14 @@ GIT_USER_EMAIL=
|
|||||||
# Detection is automatic if the skillset lives at WORKSPACE_PATH/skillset.
|
# Detection is automatic if the skillset lives at WORKSPACE_PATH/skillset.
|
||||||
# SKILLSET_CONTAINER_PATH=
|
# 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 ───────────────────────────────────────────────────────────
|
# ── Locale ───────────────────────────────────────────────────────────
|
||||||
# LANG=sv_SE.UTF-8
|
# LANG=sv_SE.UTF-8
|
||||||
# LANGUAGE=sv_SE:sv
|
# LANGUAGE=sv_SE:sv
|
||||||
|
|||||||
@@ -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 <c> 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 <file>`, 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
|
## v1.8.10 — 2026-08-27
|
||||||
|
|
||||||
**This tag exists to deploy a fix and a safety net that are currently running on
|
**This tag exists to deploy a fix and a safety net that are currently running on
|
||||||
|
|||||||
@@ -538,6 +538,35 @@ to refresh.
|
|||||||
Anything not on a volume is on the writable layer and is lost on
|
Anything not on a volume is on the writable layer and is lost on
|
||||||
container recreate.
|
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 <c>
|
||||||
|
<cmd>`, 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 integration
|
||||||
|
|
||||||
MemPalace is installed in the base image and pre-warmed with the
|
MemPalace is installed in the base image and pre-warmed with the
|
||||||
|
|||||||
@@ -188,6 +188,111 @@ if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
|
|||||||
fi
|
fi
|
||||||
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 <repo>/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 <c> 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 <file>`, 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 ──────────────────────────────────────────────
|
# ── Git config defaults ──────────────────────────────────────────────
|
||||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||||
git config --global user.name "$GIT_USER_NAME"
|
git config --global user.name "$GIT_USER_NAME"
|
||||||
|
|||||||
Reference in New Issue
Block a user