Maintainer notes on what happens between toolbox shell attaching and the zsh prompt rendering: prompt/locale plumbing, init.d/ boot scripts, and per-tool bootstrap.
Every symbol in internal/build/assets/starship.toml must be ASCII, unambiguous-narrow Unicode, or a Nerd Font PUA glyph — never an East Asian Ambiguous character or an emoji-presentation sequence (U+FE0F). Starship's defaults violate this: kubernetes ships ☸ (U+2638, EA-Ambiguous) and gcloud ships ☁️ (U+2601+FE0F). Ghostty measures them with Unicode grapheme-cluster width (mode 2027) → 2 columns, while zsh ZLE lays out the line with libc wcwidth() → 1 column. One column of drift per glyph meant every Backspace left exactly as many ghost characters as ambiguous emoji visible in the prompt — a months-long "intermittent" bug, because the k8s/gcloud modules only render where those contexts are active. Three earlier fixes (autosuggestions rebind, TERM forwarding, terminfo bundling) chased adjacent symptoms; the real confirmation was PROMPT='> ' killing the residue while plugin/RPROMPT/highlighter toggles did nothing. Diagnostic heuristic: any "ghost characters on redraw" report → test PROMPT='> ' first, before suspecting ZLE plugins. The four module symbols (kubernetes, gcloud, terraform, docker_context) are pinned to PUA glyphs with codepoint comments in starship.toml; PUA is width-1 under both width systems by construction, and Nerd Font on the host is already a README prerequisite.
Those symbols are written as escapes rather than literal characters, for a reason that has nothing to do with width: glyph escapes in image assets.
No image asset writes a Private Use Area glyph as a literal character. PUA characters do not survive every tool that edits a file: statusline-command.sh reached shipped images with every icon assignment emptied in place, and starship.toml with git_branch as a bare space while its four supplementary-plane symbols came through intact — consistent with a tool that drops the Basic Multilingual Plane PUA and leaves plane 15 alone. Nothing objects to the result: a blanked assignment is still valid bash, a blanked symbol still parses as TOML, and the glyph is simply gone.
Each file escapes them the way its own format allows and its own hazard requires. starship.toml uses TOML \u / \U, decoded by the parser with no locale involved. statusline-command.sh uses explicit UTF-8 bytes ($'\xef\x80\xa1') rather than the more readable $'\uXXXX', because bash converts a \u escape through LC_CTYPE and emits it as literal text under a C locale — which the script cannot fix from the inside, since LC_ALL outranks anything it exports and LANG is overridable by a user env: passthrough. Bytes are both pure ASCII in the source and locale-proof.
Both failures leave the file parsing cleanly, so the smoke test renders rather than parses: it runs the statusline against a fixture and asserts each icon reaches the output, once normally and once under LC_ALL=C, and it reads the baked starship.toml through a TOML parser and fails on a symbol carrying no glyph. The assertions escape their own glyphs for the same reason — a literal one is blanked in step with the bug it guards and then matches anything. Guardrail and test names: .claude/rules/image-build.md. Non-PUA symbols (▰, │, ⇡) are outside this and stay literal: they survived the failure intact, and the smoke patterns cover the bar so a future blanking of one cannot ship silently either.
The kubernetes and gcloud starship segments are opt-out via env: in .toolbox.yaml: set PROMPT_HIDE_KUBE (or PROMPT_HIDE_GCLOUD) to any value and the segment disappears; unset, it renders as before. toolbox stop first — env: is applied at ContainerCreate. Implemented with detect_env_vars = ["!VAR"], a veto rather than a trigger: starship's display test is is_project.or(has_env_vars).unwrap_or(true), and a !-prefixed var flips has_env_vars to false when the var is set. This only works for modules with no detect_files/folders/extensions — the two we toggle read a config file, not the working directory. terraform and docker_context are directory-scoped: their scan match makes is_project Some(true), which short-circuits .or(has_env_vars), so an env veto never reaches them. They're already silent outside their project dirs; to drop them entirely, edit format in starship.toml or ship a per-user STARSHIP_CONFIG.
Separately, STARSHIP_LOG=error is baked into the Dockerfile's final ENV. A dangling current-context in the user's ~/.toolbox/kube config (a context name with no matching entry — common with OKE/managed-cluster configs) makes starship log [WARN] - (starship::modules::kubernetes): Invalid KUBECONFIG on every prompt. Starship has no per-module log control, so the fix is the global log floor; it also mutes the residual git bind-mount timeout warnings the command_timeout/scan_timeout bumps in starship.toml already target. Overridable per session via env: { STARSHIP_LOG: warn } for debugging. Note this silences only the log line — the segment still renders the stale context name (verified: the prompt shows <stale-context>), so to make it vanish set PROMPT_HIDE_KUBE or fix the current-context (kubectl config unset current-context).
The image bakes ENV LANG=C.UTF-8 (Dockerfile final stage). debian-slim ships no LANG at all, so the container otherwise runs in the POSIX locale — under which zsh's ZLE cannot decode multibyte input and renders every UTF-8 byte it has to redraw as <ffffffff>. The visible symptom: typing a command that prefix-matches a history entry containing non-ASCII bytes (starship glyphs pasted into heredocs, accented letters, …) makes zsh-autosuggestions' ghost text show up as ➜ cd <ffffffff><ffffffff>. Same ZLE-encoding family as prompt glyph width, different mechanism — that one is width drift, this one is decode failure. C.UTF-8 is compiled into glibc (locale -a lists C.utf8 on a stock bookworm-slim), so no locales package or locale-gen layer is needed. Deliberately LANG only, not LC_ALL: LC_ALL outranks everything, so baking it would override any locale the user forwards via .toolbox.yaml env: passthrough. Smoke test asserts locale charmap = UTF-8.
The image bakes ENV SHELL=/bin/zsh (Dockerfile final stage). Nothing else puts SHELL in the container environment — Docker sets none, and zsh keeps SHELL as a non-exported parameter, so a process launched from the interactive shell inherits nothing. Tools that resolve the user's shell from $SHELL alone then fall back to /bin/sh: dash. They never reach the /bin/zsh passwd entry entrypoint.sh writes for the host UID, because they don't consult getpwuid() at all. herdr (agent multiplexer TUI) is the observed case, and dash explains its whole symptom cluster with one cause: no line editor means no redraw, so a TUI exiting inside a pane leaves its output on screen untouched, and every editing keypress (arrows, Backspace, history) lands as literal bytes on the command line — surfacing as bogus /bin/sh: 2: <garbled>: not found on any command typed afterwards. Confirmed by starting an isolated herdr server twice with only SHELL differing: unset → pane argv ["/bin/sh"], set → ["/usr/bin/zsh"]. tmux resolves the same way (default-shell falls back to $SHELL, then /bin/sh), so the fix covers it too. Must be an image ENV, not an export in zshrc.sh: the herdr server is a long-lived daemon, not a child of an interactive shell, and it is the process whose environment decides the pane shell. For the same consistency the baked UID-1000 passwd entry was moved off /bin/bash — bash is no longer an interactive option, and it was the shell getpwuid()-based fallbacks would have found.
The smoke test asserts the environ of PID 1 (tini), never $SHELL: its body runs under bash -c, and bash fabricates SHELL from /etc/passwd when the variable is unset, so a $SHELL assertion would stay green with the ENV dropped.
~/.zshrc is not a durable home for user config: the Dockerfile creates it with a truncating redirect (echo '# managed by toolbox …' > /out/home/toolbox/.zshrc) and it lives in the image layer, so every rebuild or image pull discards whatever the user added. The ~/.toolbox/startup.d hooks are no substitute — they are bind-mounted read-only from the host, so they cannot be authored from inside the container where the user works, and they run as bash before any shell exists. zshrc.sh therefore sources ~/.toolbox-state/zshrc.d/*.zsh, on the same read-write mount that already carries the history file, the compdump and the update-check cache. Last position in the file is deliberate: it runs after oh-my-zsh and after the alias block, so a snippet can override anything the image sets.
Snippets are sourced with alias expansion off (unsetopt aliases, restored afterwards only if it was on). zsh expands aliases at parse time, so under any shipped alias — h, g, d, k, l, tf, ll, la, plus every alias oh-my-zsh's plugins define — a snippet doing h() { … } is parse error near '()', and that error discards the rest of that file, not just the offending function. The two error lines scroll past above the prompt, so what the user reports is "part of my config is missing", which points nowhere near an alias. Disabling expansion only suppresses alias use inside the snippet: alias foo=bar still registers, and a snippet whose function must also win at command position calls unalias <name> first. setopt ALIAS_FUNC_DEF is not an alternative — it defines the function under the expanded name (helm).
The smoke test writes a snippet that defines the function before it unaliases, then asserts whence -w h is h: function. That order is the test. With unalias first the alias is already gone by the time line 2 is parsed, so the assertion passes whether or not expansion was disabled — degrading to "the file was sourced", which is precisely the weaker property it exists to rule out.
The image ships a curated Claude Code statusline at /etc/toolbox/statusline-command.sh (baked like starship.toml, never copied into the ~/.claude bind-mount). init.d/35-statusline.sh runs on every shell start and force-sets ~/.claude/settings.json statusLine to bash '/etc/toolbox/statusline-command.sh' via jq, rewriting only that key and leaving the rest of the user's settings intact. The same object carries a refreshInterval and hideVimModeIndicator: true; the interval is pinned in 35-statusline.sh next to the reasoning for it. The refresh exists for the rate-limit countdowns, the only thing left on the line that moves on its own: ticks are event-driven and go quiet while the session idles, and resets_at is an absolute epoch rendered as the time remaining, so the text goes stale with nothing to redraw it. That countdown is never finer than a minute, which is what sets the interval — anything shorter re-renders for no added accuracy. hideVimModeIndicator is set because the script renders vim.mode itself, so the built-in -- INSERT -- line would be a duplicate. It uses Agent Topology's Claude-settings lock helper alongside every other settings.json writer (entrypoint runs init.d scripts in parallel) and replaces the file atomically (mktemp+mv), seeding {} if settings.json is absent and never truncating a valid file on jq failure. Because the script is image-owned and re-applied every boot, a local edit does not persist — the statusline changes only when the image does, i.e. via a repo PR. This is why it lives baked + enforced rather than as a one-time seed.
Opt-out: managed_statusline: false in .toolbox.yaml makes the host emit the curated TOOLBOX_MANAGED_STATUSLINE=0 env (sessionplan.managedStatuslineEnv); the boot hook sees it and exits before touching settings.json, so the user keeps their own statusLine. The script resolves its own paths against ${CLAUDE_CONFIG_DIR:-$HOME/.claude} so it runs in any environment, and degrades silently when optional inputs are missing. Most of the stdin JSON is conditional, and every conditional field is rendered as its own segment that simply does not appear: agent.name (only under --agent), effort.level (only on models supporting it — falls back to effortLevel from settings.json), vim.mode, rate_limits.* (Pro/Max only, each window independently absent, and dropped by Claude Code once its resets_at passes — which is also why the countdown never has to render a deadline already gone), prompt_cache.* (absent until the first API response; the cold-cache glyph reads warm and caching_observed through tostring alone, never jq's //, which treats false as empty and would collapse the one value being tested), context_window.context_window_size (names the window beside the bar only when it is not the ordinary one), and context_window.used_percentage (null early in a session and again after /compact). One field is preferred-but-not-trusted rather than merely optional, because a missing value would silently drop information the script can still recover: workspace.git_worktree falls back to matching git rev-parse --absolute-git-dir against */worktrees/*, which can report presence but not the worktree's name. The ponytail/caveman mode badges appear only if those plugins are installed (glob no-match → no badge), and the Nerd Font glyphs render as tofu without a Nerd Font on the host. Those glyphs are written as explicit UTF-8 bytes rather than as characters or \u escapes: glyph escapes in image assets.
Four fields the JSON offers are deliberately not rendered, and the test for each was whether another surface shows it at the same moment — a Simultaneous Surface. pr.* and permission_mode: Claude Code prints both on the hint line one row below, inside the same TUI. workspace.repo.name: herdr labels its workspace with the repository name, in the sidebar. cwd: starship prints it with the same truncation. The session duration is gone for a different reason and does not belong to this list: nobody acted on it.
The branch is the deliberate exception, and the reasoning is the same test applied honestly. starship prints it, but only in the prompt, which is not simultaneous: it scrolls out of view during a session and goes stale the moment an agent switches branch itself. herdr gives every worktree workspace of one repository the same label, so the sidebar does not separate them either. The branch is therefore the one piece of git state with no other live surface. The smoke test carries both halves of that decision: one fixture supplies a PR, a permission mode and a repository name and asserts none of them reaches the output, and a second renders inside a real repository and asserts the branch does — an exception nobody tests is an exception that quietly stops existing.
init.d/36-mode-flags.sh keeps the badges honest. ponytail/caveman draw their [PONYTAIL]/[CAVEMAN] badge from a ~/.claude/.<mode>-active flag written by the plugin's own SessionStart hook — but a disabled plugin's hook never runs, so a flag left from a previous session goes stale and the badge shows for an inactive mode. This hook runs before claude starts and removes the flag for any mode whose enabledPlugins["<plugin>"] is not true; enabled plugins are left alone (their hook rewrites the flag with the live level on start, so both badges show when both are enabled). On an unreadable settings.json it defaults to "enabled" and deletes nothing — it only removes a flag on a definite false.
Both are registered as system init scripts (systemInitScripts in internal/catalog/init_d_bijection_test.go) — image policy, not catalog tool toggles — and counted in the smoke-test.sh init.d bijection literals.
internal/build/assets/init.d/50-mcp-plugins.sh scans the ~/.claude/plugins/cache/*/*/*/mcp dirs and runs npm install && npm run build for any that has a package.json, declares a build script, and is not yet built (no .toolbox-built marker). First shell after a plugin install is therefore slower; subsequent shells cached via .toolbox-built marker. On failure stderr is captured to .toolbox-build-error.log next to the marker (in the same bind-mounted plugin dir, so it survives container restarts) and the last 5 lines are printed inline; failure stays non-fatal.
internal/build/assets/init.d/40-playwright-cli.sh does two jobs. Besides the per-repo playwright-cli skill refresh (see Per-repo playwright-cli skill below), it syncs the bundled Chromium to the pinned playwright version. The Dockerfile bakes the playwright npm package + playwright install-deps chromium (apt deps) only — the browser binaries are not baked; they live in the ~/.toolbox/playwright-cache bind (host-persisted, kept out of the image). Since nothing else downloads them, a playwright Renovate bump would otherwise leave the cache on the old Chromium revision and break the default headless launch: playwright resolves chromium.launch({headless:true}) to a separate chromium_headless_shell-<rev> binary that a stale cache never fetched — observed live, with the cache holding a full Chromium at one revision and no headless shell at the revision the bumped playwright pinned. A version sentinel (<cache>/.toolbox-chromium-version, compared against the playwright package.json version — read via node rather than parsing CLI output) makes the sync a no-op on every shell except the first after a bump, when it runs playwright install chromium (full + headless shell) once. Best-effort + non-fatal: an offline shell still starts. This rides the existing 40- script (no new init.d → no TestCatalogInitDBijection / smoke-count edit).
cf Cloudflare CLI skill auto-installWhen the cf and claude binaries are present and ~/.claude exists, internal/build/assets/init.d/20-cf.sh writes a Claude Code skill to ~/.claude/skills/cf/SKILL.md if absent. Skill is hand-written and points Claude to cf agent-context <product> for on-demand product context (instead of pre-baking the ~107-product corpus). Idempotent — only re-creates when the file is missing, so user edits persist.
Both graphify (init.d/30-graphify.sh) and codegraph (init.d/31-codegraph.sh) wire themselves into a project only when that project has opted in — neither registers anything globally. Opt-in is a one-time manual step the user runs inside the repo they want indexed:
graphify install --project --platform claude (alias graphify-init) installs the project-scoped /graphify skill into the repo's .claude/skills/graphify/, writes a ## graphify section into the repo's local CLAUDE.md, and registers PreToolUse hooks in the repo's .claude/settings.json; the graph data lives in graphify-out/. graphify takes one platform per invocation and writes a separate skill copy for each, so the alias chains a second call, --platform pi, which writes .pi/agent/skills/graphify/ and touches neither CLAUDE.md nor settings.json — those are claude's alone.codegraph install --target=claude --location=local --yes (alias codegraph-init) writes the per-project MCP config + a marker-fenced section into CLAUDE.md/AGENTS.md; the symbol graph lives in .codegraph/codegraph.db.The *-init aliases (graphify-init, codegraph-init, plus pwcli-init for the playwright-cli skill) are defined in zshrc.sh as shorthands for these one-time opt-in commands. Where a tool writes a separate copy per agent, the alias opts in every agent the image bundles, because the refresh below adds an agent to a repo already opted in and never opts a repo in by itself. codegraph is the exception with nothing to chain: it has no pi target.
On every shell each script gates on the presence of the tool's marker dir in $PWD (graphify-out/ resp. .codegraph/) plus the claude binary and ~/.claude; graphify then runs a second pass for pi, gated on the pi binary and the ~/.pi bind mount, behind a stamp of its own. When the dir is present it re-runs the install so the marker/config stays in sync with the bundled tool version after an image upgrade; when absent it exits 0 and writes nothing — so opening an un-opted-in repo never dirties it. Both refreshes are idempotent and non-fatal. Because the workspace is a host bind-mount, the marker dirs, MCP config, and CLAUDE.md edits persist on the host repo across sessions; codegraph has no global DB location, so persistence is exactly the per-repo .codegraph/.
This replaces graphify's previous always-on global graphify install, which refreshed ~/.claude/skills/graphify/SKILL.md (and the /graphify slash skill) under ~/.claude/skills/ on every shell regardless of the repo. The /graphify skill is still installed, but now project-scoped into the repo's .claude/skills/graphify/ (via --project) rather than global; the graphify CLI stays bundled and the whole integration is per-repo via graphify install --project --platform claude.
playwright-cli follows the same per-repo opt-in model as graphify/codegraph above — init.d/40-playwright-cli.sh registers nothing globally. playwright-cli install initialises a workspace in $PWD: run with no cd $HOME wrapper it writes the skill to $PWD/.claude/skills/playwright-cli/ (plus a .playwright/ workspace dir), so opt-in is a one-time manual step the user runs inside the repo they want browser automation in:
playwright-cli install --skills claude writes .claude/skills/playwright-cli/SKILL.md into the repo, and --skills agents writes .agents/skills/playwright-cli/SKILL.md, the cross-agent layout Codex and pi both read. The pwcli-init shell alias (in zshrc.sh) chains both, since one opt-in should reach every agent the image bundles. Both copies are meant to be committed with the repo, which is why neither is in this repo's .gitignore — graphify's skill dirs are the opposite call, ignored under both agent roots because graphify regenerates them wholesale on every version bump.On every shell the script gates on the presence of $PWD/.claude/skills/playwright-cli/ plus the claude binary and ~/.claude. When the dir is present it re-runs playwright-cli install --skills claude (in CWD, no cd $HOME) so the skill stays in sync with the bundled playwright-cli version after an image upgrade, and a second pass re-runs --skills agents behind its own stamp whenever codex or pi is present — the SKILL.md + references are copied from the templates bundled in the playwright-cli package (kept in the image; the .md weight-prune is made to spare them — see Node package weight prune), so a Renovate PLAYWRIGHT_CLI_VERSION bump refreshes the skill on the next shell, offline. When the dir is absent it exits 0 and writes nothing, so opening an un-opted-in repo never dirties it. Idempotent and non-fatal; because the workspace is a host bind-mount the skill dir persists on the host repo across sessions.
This replaces the previous always-on (cd "$HOME" && playwright-cli install --skills claude), which forced the otherwise-CWD-local install into ~/.claude/skills/playwright-cli/ on every shell regardless of the repo. The cd $HOME wrapper was the global switch; dropping it makes the integration per-repo. An existing global ~/.claude/skills/playwright-cli/ from the old behaviour is left as-is — it is simply no longer refreshed (matching how graphify left its old global skill in place).
This is unrelated to the global playwright browser-cache sync in the same script, which stays always-on (it tracks the playwright package, not the per-repo playwright-cli opt-in).
Claude Code reads only ~/.claude/skills/<name>/SKILL.md (per docs.claude.com); Codex CLI and pi read only ~/.agents/skills/<name>/SKILL.md (Agent Skills USER scope per agentskills.io). Despite the shared "Agent Skills" branding, the two locations are NOT mutually compatible. The split is by root, not per agent — every bundled agent other than Claude reads the cross-agent root — so a CLI wrapper that ships a SKILL.md needs a dual-install pass, not one pass per agent, to be visible everywhere. The relevant scripts do it differently:
internal/build/assets/init.d/02-gh.sh installs the official gh skill from cli/cli with gh skill install: once into Claude's skill directory and once into the shared cross-agent directory. Both network-backed installs are non-fatal, so an offline shell still starts.internal/build/assets/init.d/60-glab.sh delegates: glab skills install --path "$CLAUDE_CONFIG_DIR/skills" --force for Claude (falling back to ~/.claude), and glab skills install --global --force for the cross-agent root when Codex or pi is present.internal/build/assets/init.d/61-herdr.sh writes the file itself, from herdr --skill — the binary prints its own version-matched SKILL.md, so there is no package to pin and no network call. Its ~/.agents pass is gated on Codex or pi, one write for both readers; gate it on Codex alone and a pi-only shell gets no skill.All of these scripts consume the Agent Topology module in bin/agent-topology-lib.sh; none derives the root matrix itself. Claude's native and skill roots follow CLAUDE_CONFIG_DIR with the ~/.claude fallback, while Codex's native root follows CODEX_HOME with the ~/.codex fallback. The Dockerfile sets both and the tools honour them, so probing a bare $HOME path can skip an install that would have landed. Codex and pi share the cross-agent skill root. ~/.agents is container-local — unlike the native state roots it is no bind mount — so callers create it and never use its existence as a persistence gate. toolbox_for_each_active_skill_root visits it once when either reader is available, preventing both duplicate installs and a race against another init.d member.
herdr integration install claude additionally registers its hook in Claude's settings.json, alongside the RTK migration, managed statusline and Atuin. Every writer calls Agent Topology's resource-specific lock helper; skill writes and the independent Codex and pi hook files take no unrelated lock. TestAgentTopologyOwnsClaudeSettingsLock and TestHerdrInitLocksClaudeSettings hold the two sides of that interface.
pi's persistence gate is its bind mount, ~/.pi, because no relocation variable is baked into the image. Not ~/.pi/agent: pi creates that on its own first run whether or not the state is mounted, so gating there would wire an agent whose hooks vanish on the next toolbox stop. The child directory is then created before herdr integration install pi, which refuses a fresh state root it has not populated. TestHerdrInitWiresPi holds the gate and creation; TestAgentWiringDeclaresEverySupportedAgent makes Herdr and Atuin account for every supported agent.
sessionplan.shellEnv emits HERDR_SESSION=ContainerNameFor(workspace, ""), and the reason is a mount: ~/.config/herdr is bound from the host-global ~/.toolbox/herdr/config, so a single herdr session state is shared by every toolbox container. herdr persists its workspace list there with absolute cwds and, on restore, ignores the startup cwd — its log says so verbatim: restored session already has workspaces; ignoring startup cwd. Unnamed, a container therefore reopens whatever project saved last; that path is not mounted here, and herdr answers a missing cwd by silently falling back to $HOME. The symptom is a shell that opens on /home/toolbox with the workspace labelled ~, the only visible entry being the ~/go mount.
The discriminator is deliberately empty, which makes the value the plain workspace identity (slug + path hash). A --peer or --profile change forks the container name over the same mounted workspace, and the session survives it. The flip side: a named shell pointed at the same path as a workspace session shares that session — two containers, one saved layout. Deliberate, and not the failure above: both mount that path, so every restored cwd stays valid.
Env is fixed at ContainerCreate, so a pre-existing container needs a toolbox stop before it sees the variable, and the pre-fix unnamed session.json stays on disk, orphaned (herdr --session '' still reaches it).
init.d/60-glab.sh registers !glab auth git-credential as the git credential helper for every authenticated host in glab's config (yq '.hosts | keys | .[]' ~/.config/glab-cli/config.yml) — gitlab.com and any self-hosted instance the user has run glab auth login --hostname <host> for; new hosts need zero code changes.
A bare glab auth status is the fast path, and settles the common all-healthy case in one glab process. That matters beyond speed: glab rewrites the whole config.yml whenever a probe refreshes an expired token, and that file is a single host-shared mount, so each extra glab process is another unlocked read-modify-write racing the other containers. Only when the bare probe fails does the script probe one host at a time, to learn which host is rejected — a bare probe exits non-zero when any configured host fails, so gating registration on it alone costs every healthy host its helper the moment one OAuth session expires. With single-use refresh tokens in a host-shared config, that is a normal steady state rather than an edge case. Rejected hosts are reported through the D-08-creds-tristate middle state (glab: auth check failed for <host> …), which names the host and the glab auth login --hostname <host> that fixes it, and never collapses onto not configured — that lumping is what the decision removed, because it hid expired credentials.
Registration is written with sudo git config --system into the container's /etc/gitconfig: the system file is container-local and dies with the AutoRemove container, keeping the helper out of the host's ~/.gitconfig (which is a read-write host-synced mount — writing there would pollute the real host config). Registration is non-fatal — on failure a warning points at the SSH fallback (git@<host>:… keeps working via the RO ~/.ssh mount).
Primary consumer: private Homebrew taps over HTTPS — brew tap <name> https://<gitlab-host>/<group>/homebrew-tap.git clones with the glab token, no prompts, no extra setup (the token already persists in ~/.toolbox/glab). Benefits any in-container git clone/pull of private GitLab repos.
Limitation: the helper covers git transports only. Formulas that download release assets / package-registry artifacts over HTTPS go through brew's curl, which does not consult git credential helpers — such formulas need a custom download strategy reading a token. Revisit if a private tap grows that kind of formula.
Cross-container flock, closing the race the paragraph above only throttles: 60-glab.sh still races whenever two containers boot together, or a user runs glab by hand in one container while another's init.d probe fires — the shared config.yml has no protection beyond "call glab less often". /usr/local/bin/glab in the image is therefore not the real binary but a shim serializing every invocation on flock $HOME/.config/glab-cli/.toolbox-lock (the real binary moved to /usr/local/libexec/glab, fetch-glab stage in the Dockerfile). Advisory locks on a shared bind mount are visible across every container mounting it, so two toolbox containers racing a token refresh now queue instead of both PATCHing config.yml with the same single-use refresh_token — the loser of that race gets invalid_grant from GitLab, and GitLab can revoke the whole token family, forcing a full re-login on every host. -w 10 bounds the wait: a human sitting in an interactive glab auth login holds the lock for as long as they take, and blocking every other container's glab call indefinitely would be worse than the race it avoids, so a caller that times out just runs unlocked. The shim guards its own re-entrancy (TOOLBOX_GLAB_LOCKED) because the credential helper it wires above resolves back to itself via command -v glab.
Two properties matter more than the locking, and both are regression-tested in internal/build/glab_flock_shim_test.go (which reassembles the shim out of the Dockerfile printf block and runs it — the defects this shim has actually shipped were behavioural, and each was spelled correctly enough to satisfy any needle match):
HOME unset, a read-only config dir, a config dir owned by a foreign UID — all fall through to the unlocked path. The first cut read "$HOME" under set -u and probed only with mkdir -p, which returns 0 for an existing directory it cannot write into; both made glab refuse to run at all while this document claimed it degraded.exec, so one execution covers glab's whole run. The first cut used flock -w 10 -E 99 and read exit 99 as "timed out, retry unlocked" — but flock passes the child's status through verbatim, so a glab genuinely exiting 99 got its command re-run, repeating the side effects of whatever mutating call the user made.Because the timeout falls through to an unlocked run, the lock cannot protect a race the user starts deliberately: authenticate glab in one container at a time. That caveat is repeated where users meet the command, in commands.md.
The lock file lives inside glab's own config dir, so under inherit_host_auth: [glab] (catalog.go, HostAuthMount{HostPath: "~/.config/glab-cli"}) it is written into the user's real host config directory rather than the toolbox-owned ~/.toolbox/glab. glab ignores dotfiles it does not own, so this is cosmetic — but it is host-visible, which is why it is named here.
On Docker Desktop for macOS, the workspace bind arrives as virtiofs. After git
rewrites a file, virtiofs can immediately report a stat identity field
differently from the value git just recorded even though the content is
unchanged. The failure is confined to fields that core.checkStat = minimal
omits — inode, device, uid or gid — rather than ctime alone. Multi-file
operations such as rebase, cherry-pick and checkout can then reject a clean
worktree with Your local changes … would be overwritten by merge; git status remains clean because hashing the file finds no content change.
At startup, entrypoint.sh asks findmnt for the filesystem containing the
working directory. Only when that filesystem is virtiofs, it writes
core.checkStat = minimal to the container's system gitconfig under the shared
/tmp/toolbox-gitconfig.lock. Native Linux filesystems keep git's default stat
check, and failed filesystem detection changes nothing. System scope is
intentional: the setting describes the running container's workspace mount,
while ~/.gitconfig is host-synced and must not be changed.
The trade-off is explicit: minimal compares whole-second mtime and ctime plus
size, but omits inode, device, uid, gid and sub-second timestamps. A same-size
edit made within the same second can therefore escape the initial stat check
until git refreshes the index. That window is accepted only on virtiofs, where
including the unstable identity fields makes ordinary multi-file operations
fail. The detection and registration policy is held by
TestVirtiofsCheckStatRegistration. The platform boundary is held separately
by .github/scripts/virtiofs-git-rewrite.sh, run through make test-virtiofs:
it builds the image (overwriting the local registry tag, as make test does),
binds a fresh repository through Docker Desktop, checks
that the bind is virtiofs and the policy active, and rebases shared-file
rewrites on the real mount. Without the policy the same rebase fails with the
false would be overwritten by merge error, so the gate tells the two apart.
The gate runs by hand on a Mac with Docker Desktop, never in CI. GitHub-hosted
macOS runners cannot run Docker Desktop, and a self-hosted runner is not worth
its exposure here: the repository is public and owned by a user account, which
has no runner groups to restrict a runner to one workflow, so any approved fork
PR could edit a workflow to target it. Three events can break the boundary: a
change to the entrypoint policy, which the pre-push table in CLAUDE.md maps
to this target; a Docker Desktop upgrade; and a new git release in the image,
which arrives only with a new Debian release of the base image, since git comes
from apt. The last two touch no file the table can key on, so each is worth one
run of the gate when it lands. The script's exit
trap owns its container and temporary repository; a killed run leaves the
repository behind as a gitignored .toolbox-virtiofs-git.* directory.
entrypoint.sh registers one safe.directory entry — the wildcard — in the container's system gitconfig. Since the entrypoint is baked into the image, a container that predates a change to this block does not get it: rebuild or pull, then let the container be recreated.
What it works around, measured rather than assumed: a bind-mounted directory transiently reports uid 0 while its own contents keep the host uid. A probe looping every 2s caught 95 failures on the workspace, 91 of them showing euid=501 and mount point uid=0 in the same instant, with .git one level down at uid=501 throughout; the remaining 4 had already recovered by the time the probe ran its own stat, which is the same millisecond-window shape. git checks the worktree, not the files inside it, so it refuses the repo:
fatal: detected dubious ownership in repository at '/Users/…/toolbox'
In 45 of those 95 a second container was mounting the same host path at the sampled instant (a make target: golang, lychee, golangci-lint, running as root over the same bind), so that is a strong hint rather than a proven trigger — and an undercount, since those containers exit quickly and the sample is taken after the failure. Either way the wrong uid arrives from Docker Desktop's file sharing, not from anywhere in this image. Before that capture the symptom looked unfalsifiable from the inside: 13 occurrences across four repos in three days, every one naming the workspace mount point, every follow-up stat showing the expected uid, no root-owned file anywhere under .git.
Worth knowing when reading that fatal: it covers three different situations, and never says which one you have — a repo genuinely owned by another uid (what Homebrew's own build-time entry is for), a mount point reporting the wrong uid (this case), and an ownership question git could not answer at all, since is_path_owned_by_current_uid() reads "not mine" from any lstat() failure. safe.directory covers all three, because git consults it whenever the ownership check does not pass.
/workspace plus $TOOLBOX_HOST_WORKSPACE were the original entries, and enumerating is what made them insufficient. ~/.claude is a bind mount of the same kind, and claude plugin update clones every git-subdir plugin into a randomly named directory under ~/.claude/plugins/cache, then fetches the pinned commit inside that clone. git matches safe.directory against the worktree root it discovered, and that root does not exist yet when the entrypoint runs — so no list written at boot can name it. Each failed update leaves its clone behind, so the symptom is a per-plugin Failed to fetch commit …: fatal: detected dubious ownership plus a cache that keeps growing. It looks bursty in the same way the workspace flake is — consecutive updates fail, one then succeeds with no config change, while a manual git in the same directory works throughout — but that is an observation from a handful of sessions, not a probe of the shape the workspace got.
Naming the cache directory does not reach it either, and neither does a glob. Measured as of 2026-09-03 against the git this image ships (unpinned, from the base apt block), on a worktree root chowned to uid 0 under the real plugin cache while its contents keep the runtime uid:
| entry | covers the clone |
|---|---|
/home/toolbox/.claude (mount root, exact) |
no |
/home/toolbox/.claude/plugins/cache (exact) |
no |
/home/toolbox/.claude/plugins/cache/* |
no |
/home/toolbox/.claude/*, …/**, …/*/ |
no |
| the exact clone path | yes |
* |
yes |
Only an exact path or the wildcard matches. A newer git does honour a trailing /*, recursively at any depth — which is why enumerating looked plausible, and why a glob entry lifted from current git documentation fails silently here rather than erroring. That also removes the one gap the earlier per-path registration left open: a nested repo, submodule or worktree under the workspace no longer needs an entry of its own.
What the wildcard costs, stated rather than waved away: it trusts more than the paths it replaces. git will honour the config and hooks — core.fsmonitor, core.hooksPath — of a repository owned by some other uid that reaches the container through any mount, where before only the workspace root was trusted blanket and a foreign-uid repository under it was still refused. Three things make that a reasonable price rather than no price at all: enumeration cannot cover the failure this fixes, at all, so the alternative is not a narrower entry but a broken claude plugin update; the check never protected against a hostile repository the user cloned themselves, since that one already carries their own uid; and a container that runs as a single uid with passwordless sudo is not a boundary anything inside it would have to cross. What remains is the narrow case of a foreign-uid repository arriving through a mount that the user then runs git inside.
The root-owned Homebrew clone keeps its own system entry from the image build — redundant at runtime now, still the thing that describes why that clone is special.
Four properties, held by TestSafeDirectoryRegistration (internal/build/safe_directory_test.go, which reassembles the block from the embedded entrypoint and runs it against stub sudo/flock/git):
/etc/gitconfig is container-local and dies with the AutoRemove container; the host ~/.gitconfig is a RW mount and must not be polluted — the same discipline as the credential helper and init.d/60-glab.sh. --global, which is what git's own message suggests, cannot work here regardless: that mount is a bind mount of a single file, so git's rename-in-place write fails with Device or resource busy.30-graphify.sh asks git whether the workspace is a work tree with output suppressed, so an ownership fatal there skips the hook install without saying a word. Offset-checked against the init.d dispatch, not just present in the file.toolbox shell reaches the container through ExecCreate on the resolved shell command (container/attach.go), never through the entrypoint. So what re-runs it is runplan.ActionStart on a stopped container — rare under AutoRemove, not impossible for a legacy one. The block therefore reads --get-all before it --adds. It takes /tmp/toolbox-gitconfig.lock as the house discipline for /etc/gitconfig (the credential helper and init.d/60-glab.sh take the same one) rather than against a live race: sitting above the init sequence is what keeps it clear of 60-glab.sh, whose scripts do run in parallel — the lock is what would keep it correct if the block ever moved below them.smoke-test.sh holds the other half, behaviourally: a worktree root that is both foreign-uid and at an unenumerable path has to be accepted. A grep for a named entry would have passed while the reported failure stood.
For a one-off command in a container that predates this block, the per-command form writes no config at all: GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=safe.directory GIT_CONFIG_VALUE_0='*' git ….
It is dead under Claude Code, and the reason generalises. The numbered variables are one flat set indexed from zero, not a stack: anything further down the chain that exports its own GIT_CONFIG_COUNT with GIT_CONFIG_KEY_0/VALUE_0 replaces what the caller set rather than adding to it — measured, the caller's entry is simply gone from git config --list. Claude Code does exactly that: every git subprocess it drives itself is handed its own numbered set, hardening keys of its own choosing (credential.interactive, core.hooksPath, core.askPass, protocol.ext.allow and more), which displaces whatever the launching shell had put there.
Two things follow for anyone debugging in this area. First, GIT_CONFIG_GLOBAL pointing at a file that includes the user's own config is the shape that survives — it composes instead of replacing, and it is what the env block of ~/.claude/settings.json should carry. Second, env in the session's own shell is the wrong instrument for this question: it cannot separate what a spawned CLI injects into its subprocesses from what the session merely inherited, and that env block is itself propagated into every subprocess and outlives its own deletion, since a shell keeps the environment it booted with. A key removed from settings therefore keeps applying until the session ends — enough to make a deterministic failure look intermittent, or a workaround look effective when something stale was doing the work. The measurement that answers it is a git shim on PATH that logs the argv and environment of each git subprocess, run from a shell with the whole numbered set cleared (env -u GIT_CONFIG_COUNT -u GIT_CONFIG_KEY_0 …).
This is a mitigation, not a fix: the uid the container sees is still wrong for those windows, and every other tool that cares about ownership stays exposed. git is simply the one that fails loudly enough to notice.