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.
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 refreshInterval: 30 and hideVimModeIndicator: true. The refresh exists for the session duration only: ticks are event-driven, so they go quiet while the session idles and the clock would sit still until the next assistant message. It is deliberately not justified by the rate-limit segment — resets_at is an absolute epoch rendered as a fixed wall-clock time, so that text never ticks and would not benefit. hideVimModeIndicator is set because the script renders vim.mode itself, so the built-in -- INSERT -- line would be a duplicate. It shares the ~/.toolbox-state/.claude-settings.lock flock with 10-rtk.sh / 65-atuin.sh (entrypoint runs init.d scripts in parallel; all three write the same file) 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: pr.* (only while an open PR exists for the branch; pr.review_state may be absent even when pr is present, which falls through to the neutral colour), 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 context_window.used_percentage (null early in a session and again after /compact). Two fields are preferred-but-not-trusted rather than merely optional, because a missing value would silently drop information the script can still recover: workspace.repo.name (absent without an origin remote) falls back to basename $(git rev-parse --show-toplevel), and 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.
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, not playwright --version, to dodge the rtk wrapper) 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/.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.
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. 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 (--skills also accepts agents for the Codex/~/.agents layout). The pwcli-init shell alias (in zshrc.sh) is a shorthand for exactly this.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 — 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 reads 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. CLI wrappers that ship a SKILL.md need a dual-install pass to be visible in both agents. Two scripts do it, differently:
internal/build/assets/init.d/60-glab.sh delegates: glab skills install --path ~/.claude/skills --force for Claude, glab skills install --global --force for Codex, gated on the respective binaries.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.Two traps the second script exists to document. Roots come from CLAUDE_CONFIG_DIR / CODEX_HOME with the ~ fallback, never a bare $HOME path: the Dockerfile sets both and the tools honour them, so probing $HOME/.claude directly can skip an install that would have landed. And ~/.agents is container-local — unlike ~/.claude it is no bind mount — so it must be created, never gated on: init.d scripts run backgrounded in parallel, and gating leaves the Codex skill landing or not depending on whether 60-glab.sh happened to create the directory first. TestHerdrInitInstallsBothSkillPaths holds both.
herdr integration install claude additionally registers its hook in ~/.claude/settings.json, which makes 61-herdr.sh the fourth concurrent writer of that file alongside 10-rtk.sh, 35-statusline.sh and 65-atuin.sh — hence the shared .claude-settings.lock (TestHerdrInitLocksClaudeSettings).
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.
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.