From 30094782df70f0eab99a0c02ce1a939d60d61572 Mon Sep 17 00:00:00 2001 From: "pi@mbp-m1-2020" Date: Sun, 30 Aug 2026 11:48:03 +0200 Subject: [PATCH] shell: source cli_utils' functions, and install the iproute2 that one of them needs v1.8.11 linked cli_utils' bin/ COMMANDS onto PATH and stopped there. Nothing ever sourced cli_utils.sh, so its 14 FUNCTIONS were missing from every interactive shell whose $HOME has no zsh rc -- which is the normal case, not an edge case: the container's interactive shell is bash and zsh is not installed in the image. A symlink cannot carry a shell function and a function cannot be reached from a non-interactive shell, so the two mechanisms are disjoint and both are required. The image was already paying this layer's dependency cost (fzf, bat, fd, rg, jq are baked partly FOR these functions) while delivering none of its benefit. The two changes ship together because they are coupled: portcheck is one of the 14, and it was a hard stub in every image up to v1.8.11 -- neither ss nor ip nor lsof nor netstat was present, so it printed "portcheck requires at least one of: ss, lsof, netstat" and exited. Wiring the functions in without iproute2 would have shipped a visibly broken one. MEASURED, not assumed: - the loader is bash-safe despite the *.zsh filenames: `bash --noprofile --norc` exits 0, defines all 14, and they run (pathls, mkcd, up, extract, agents-sync, fhist verified). The tree's one zsh-only construct (print -z in fzf/fhist.zsh) is already guarded by [[ -n $ZSH_VERSION ]] with a bash fallback. - fresh-$HOME seeding resolves 14/14; CLI_UTILS_SOURCE=0 is honoured; an absent checkout is a genuinely silent no-op (no output, no leaked _cu). - interactive shell startup 12 ms -> 17 ms. - iproute2 is ~5.5 MB (4.2 MB itself + 6 libs under --no-install-recommends; libpam-cap is a Recommends and correctly dropped). ss lands at /usr/bin/ss, ip at /usr/sbin/ip, both already on the developer PATH, and `portcheck --all` then correctly identifies the socat listener on 8765. - hadolint clean on both Dockerfiles; repo-wide shellcheck -S error and bash -n clean. .bash_aliases is outside CI's discovery (no shebang, not *.sh), so it was checked by hand with -s bash at error AND warning level. Named explicitly per this repo's floating-ref rule: /workspace/cli_utils is a HOST BIND MOUNT, not a pinned ref, so the image now executes unpinned content in every interactive shell. Errors are left visible rather than sent to /dev/null so that a future zsh-only file in that repo is diagnosable rather than mysterious, and CLI_UTILS_SOURCE=0 is the documented escape hatch. It is deliberately independent of CLI_UTILS_LINK=0: the two disable independent mechanisms. Deployment: needs a rebuild AND a recreate. $HOME is the container's writable layer rather than a named volume (verified -- ~/.bash_aliases carries the container start mtime while ~/.bashrc carries the image's), so the skel file is re-seeded on every recreate; a host-bind-mounted ~/.bash_aliases is still never overwritten. --- CHANGELOG.md | 58 ++++++++++++++++++++++++++++- Dockerfile.base | 19 ++++++++++ rootfs/home/developer/.bash_aliases | 44 ++++++++++++++++++++++ 3 files changed, 119 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f16bbf..4c75c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,10 +73,64 @@ by `pi-devbox-version`. Skill directories are picked up by a glob in assumed, since an enumerated list would have left the skill inert, which would have been a fitting way for *this* skill to fail. -Neither change reaches a running container until the image is rebuilt **and** the -container recreated: `~/.agents/skills/` and the global `AGENTS.md` both live in +Neither skills change reaches a running container until the image is rebuilt **and** +the container recreated: `~/.agents/skills/` and the global `AGENTS.md` both live in the image, not in a volume or a mount. +**`cli_utils`' shell *functions* are now sourced, closing the half of that wiring +the image never did.** v1.8.11 linked the repo's `bin/` **commands** into +`~/.local/bin` so they resolve in non-interactive shells; nothing ever sourced +`cli_utils.sh`, so its 14 **functions** (`fgit`, `fhist`, `fssh`, `fdocker`, +`fmark`, `fproc`, `fex`, `fenv`, `extract`, `mkcd`, `pathls`, `portcheck`, +`agents-sync`, `up`) were missing from every interactive shell whose `$HOME` had +no zsh rc. That is the normal case, not an edge case: the container's interactive +shell is bash and **zsh is not installed in the image**. A symlink cannot carry a +shell function and a function cannot be reached from a non-interactive shell, so +the two mechanisms are disjoint and both are required — the image had been paying +this layer's dependency cost (`fzf`, `bat`, `fd`, `rg`, `jq` are baked partly *for* +these functions) while delivering none of its benefit. Now sourced from +`/etc/skel-devbox/.bash_aliases`, with the same detection order as the symlink +block so commands and functions can never come from two different clones. +`CLI_UTILS_SOURCE=0` opts out, deliberately independent of `CLI_UTILS_LINK=0` +because the two disable independent mechanisms. Measured: all 14 resolve in a +freshly-seeded `$HOME`, the opt-out is honoured, an absent checkout is a genuinely +silent no-op (no output, no leaked `_cu` variable), and interactive shell startup +goes from 12 ms to 17 ms. + +**Named explicitly, per this repo's own floating-ref rule: `/workspace/cli_utils` +is a host bind mount, not a pinned ref.** Sourcing it means the image now executes +content it does not pin, on every interactive shell, on every device. It is +bash-safe today and that was measured rather than assumed — sourcing under +`bash --noprofile --norc` exits 0 and defines all 14 despite the `*.zsh` +filenames, the functions run, and the tree's single zsh-only construct (`print -z` +in `fzf/fhist.zsh`) is already guarded by `[[ -n $ZSH_VERSION ]]` with a bash +fallback. The residual risk is future content: a cli_utils commit adding a +genuinely zsh-only file would surface as parse errors at every prompt, fleet-wide. +Errors are therefore left visible rather than sent to `/dev/null`, so the failure +is diagnosable, and `CLI_UTILS_SOURCE=0` is the one-line escape hatch. + +**`iproute2` is installed, so the container can answer "what is listening in +here".** Neither `ss` nor `ip` was present in any image up to and including +v1.8.11 — nor `lsof`, nor `netstat` — which made `cli_utils`' `portcheck` a hard +stub that printed `portcheck requires at least one of: ss, lsof, netstat` and +exited. `ss` satisfies its preferred branch (`ss -tlnp`), which is also the only +branch that reports the owning PID. `net-tools` is deliberately **not** added +(`netstat` is deprecated and only a fallback path) and neither is `lsof` (~500 KB +for a third route to the same answer). Cost measured, not estimated: ~5.5 MB total +— `iproute2` is 4.2 MB and pulls six libs under `--no-install-recommends` +(`libbpf1`, `libmnl0`, `libtirpc-common`, `libtirpc3t64`, `libxtables12`, +`libcap2-bin`; `libpam-cap` is a Recommends and is correctly dropped). Verified in +a live container: `ss` at `/usr/bin/ss`, `ip` at `/usr/sbin/ip`, both already on +the developer `PATH`, and `portcheck --all` then correctly identifies the `socat` +listener on 8765. + +The two changes above also need a rebuild **and** a recreate, for a different +reason than the skills: `$HOME` is the container's writable layer rather than a +named volume (verified — `~/.bash_aliases` carries the container's start mtime +while `~/.bashrc` carries the image's), so the skel file is re-seeded on every +recreate. A `$HOME/.bash_aliases` that is bind-mounted from the host is still +never overwritten, which is the existing contract. + --- ## v1.8.11 — 2026-08-27 diff --git a/Dockerfile.base b/Dockerfile.base index 92e5047..12e194d 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -83,6 +83,24 @@ ENV DEBIAN_FRONTEND=noninteractive # above); TERM=xterm-ghostty is compiled from an alias further # down (ncurses ships `ghostty`, not `xterm-ghostty`). iTerm2 # defaults to xterm-256color (ncurses-base), so needs nothing. +# iproute2 — `ss` (socket statistics) and `ip`. Measured 2026-08-30 on +# v1.8.11: NEITHER was present, so the container could not +# answer "what is listening in here" by any means, and +# cli_utils' `portcheck` was a hard stub — it prints +# "portcheck requires at least one of: ss, lsof, netstat" and +# all three were absent. `ss` satisfies its preferred branch +# (`ss -tlnp`), which is also the branch that reports the +# owning PID, so nothing further is needed: net-tools is +# deliberately NOT added (`netstat` is deprecated and only a +# fallback branch) and neither is lsof (~500 KB for a third +# path to the same answer). ~5.5 MB total: iproute2 itself is +# 4.2 MB and pulls 6 libs under --no-install-recommends +# (libbpf1, libmnl0, libtirpc-common, libtirpc3t64, +# libxtables12, libcap2-bin — libpam-cap is a Recommends and +# is correctly dropped). Verified end-to-end in a live +# container: `ss` lands at /usr/bin/ss, `ip` at /usr/sbin/ip +# (both already on the developer PATH), and `portcheck --all` +# then correctly identifies the socat listener on 8765. RUN apt-get update && \ apt-get upgrade -y --no-install-recommends && \ apt-get install -y --no-install-recommends \ @@ -122,6 +140,7 @@ RUN apt-get update && \ nano \ kitty-terminfo \ ncurses-term \ + iproute2 \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/rootfs/home/developer/.bash_aliases b/rootfs/home/developer/.bash_aliases index b17e5ae..b337363 100644 --- a/rootfs/home/developer/.bash_aliases +++ b/rootfs/home/developer/.bash_aliases @@ -116,6 +116,50 @@ if command -v fzf >/dev/null 2>&1; then eval "$(fzf --bash)" 2>/dev/null || true fi +# cli_utils — shell FUNCTIONS (fgit, fhist, fssh, portcheck, up, mkcd, extract, +# agents-sync, …). This is the OTHER HALF of the cli_utils wiring, and until +# v1.8.11 the image shipped only one half. entrypoint-user.sh symlinks the repo's +# bin/ COMMANDS into ~/.local/bin, which is what makes them resolve in +# NON-interactive shells (docker exec, agent tool shells, scripts). A symlink +# cannot carry a shell function, and a function cannot be reached from a +# non-interactive shell, so the two mechanisms are disjoint and both are +# required. Nothing sourced the loader: measured 2026-08-30 on v1.8.11, all 14 +# functions were simply missing on a device whose $HOME has no zsh rc — which is +# the normal case, since the container's interactive shell is bash and zsh is not +# installed in the image. The image was already paying this layer's dependency +# cost (fzf, bat, fd, rg, jq are all baked partly FOR these functions) while +# delivering none of its benefit. +# +# Detection order deliberately mirrors the symlink block in entrypoint-user.sh so +# that commands and functions can never come from two different clones. +# CLI_UTILS_SOURCE=0 opts out. That is independent of CLI_UTILS_LINK=0 on purpose: +# they disable independent mechanisms, and someone who wants PATH commands +# without 14 extra functions in every prompt (or vice versa) should be able to +# say so. +# +# THE LOADER IS BASH-SAFE, MEASURED, NOT ASSUMED: despite every function file +# being named *.zsh, sourcing cli_utils.sh under `bash --noprofile --norc` exits +# 0 with no errors and defines all 14, and they run (pathls, mkcd, up, extract, +# agents-sync, fhist all verified). The single zsh-only construct in the tree +# (`print -z` in fzf/fhist.zsh) is already guarded by [[ -n $ZSH_VERSION ]] with +# a bash fallback, and the loader's own header states "bash & zsh compatible". +# ACCEPTED RISK, stated plainly: /workspace/cli_utils is a HOST BIND MOUNT, so +# unlike a pinned git ref this content floats outside the image's control. A +# future cli_utils commit that adds a genuinely zsh-only file would surface as +# parse errors at every prompt on every device. Errors are left VISIBLE rather +# than sent to /dev/null so that failure is diagnosable instead of mysterious, +# and CLI_UTILS_SOURCE=0 is the documented one-line escape hatch. +if [ "${CLI_UTILS_SOURCE:-1}" != "0" ]; then + for _cu in "${CLI_UTILS_CONTAINER_PATH:-}" /workspace/cli_utils "$HOME/cli_utils" /workspace/*/cli_utils; do + [ -n "$_cu" ] || continue + if [ -r "$_cu/cli_utils.sh" ]; then + . "$_cu/cli_utils.sh" || true + break + fi + done + unset _cu +fi + # ── PROMPT_COMMAND: flush history every prompt ─────────────────────── # Installed AFTER zoxide init so zoxide's hook is already in place; # we append with a newline separator to avoid the ';;' parse error