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