Agent Toolbox — Baked vs. Self-Installed
The Brain container’s toolchain has two layers: a small, deterministic base set in the image (
gh, installer set, JDK 25 via GraalVM base) and everything else as runtime self-provisioning by the agent — user-space, without root, to/app/data/tools, added toPATHat boot viaenv.sh. TheVANCE_EXEC_ENVmarker tells the agent which execution context applies (container vs. workstation); this document is the design reference, the LLM-facing pattern (commands, rules) is in theself-installed-toolsmanual (_vance/manuals/).
1. Two Layers of the Toolchain
| Layer | Content | Who decides |
|---|---|---|
| Image | gh, installer set (curl, git, unzip, tar, zip), JDK 25 (GraalVM, incl. javac), Node 20 + npm, Python 3.12 + pip |
Operator, via Dockerfile change + redeploy |
| Runtime | JVM toolchains (Maven, Gradle, other Java versions), static single binaries | The agent, on demand, in the running pod |
The boundary is deliberately drawn as follows:
ghis baked because it must be present from the first round — it is the standard interface to GitHub (issues, PRs, releases, clone credentials viagh auth setup-git) and thus often precisely the tool needed before the first self-installation.- JDK/Maven are not baked. The GraalVM base is already the current LTS (JDK 25) as a full JDK —
javacincluded; “install current LTS JDK” is covered by the base. Maven & co. are large, moving targets over the wire (cf. the layer discipline in the Dockerfile: “One RUN per concern, ordered most-stable-first”), but have low installation costs in user-space — they belong in the runtime layer. - The installer set is baked (
curl,git,unzip,tar,zip) so that self-provisioning never fails due to missing tools. The agent installs the tools, not the installers.
2. Execution Contexts and the Marker
exec_run runs in fundamentally different locations depending on the constellation — a manual describing only one would be incorrect rather than just useless for the others:
| Context | Where exec_run runs |
Marker |
|---|---|---|
| A. Deployed Brain, WORK-Target | in the container | VANCE_EXEC_ENV=container |
| B. Local Dev Brain (IDE, MacBook) | on the developer’s workstation | unset |
| C. Deployed Brain, CLIENT-Target | on the user’s machine via foot (client_exec_run) |
unset |
The Marker: ENV VANCE_EXEC_ENV=container in the brain-Dockerfile. It is a deliberate first-class signal instead of path heuristics: set = commands run in the image, unset = workstation. One value is sufficient for docker-compose as well as k8s, because the manual formulates persistence against the pod lifetime, not against specific volume mounts — if the deployment later provides persistent storage under /app/data, the formulation remains correct, only the frequency of installations decreases.
Sealed-Exec Contract: The marker is in the INHERITABLE base of ExecEnvPolicy (sealed-by-default, Issue #62) — exec_run children therefore always see it, otherwise the container would be indistinguishable from a workstation for the manual. The toolchain variables of the env.sh hook (SDKMAN_DIR, SDKMAN_CANDIDATES_DIR, JAVA_HOME, MAVEN_OPTS, GRADLE_USER_HOME, PIP_CACHE_DIR, possibly GH_TOKEN) are deliberately not a base default, but are located as VANCE_EXEC_ENV_ALLOW (=vance.exec.env.allow) in the brain-Dockerfile next to the marker — everything else, especially the credentials of the deployment environment, remains withheld from subprocesses.
Contexts B and C share the workstation branch of the manual (brew/apt, ask-first, no /app/data). The difference: In C, the foot sandbox already provides a mechanism gate (deny→allow→ask, default-deny — see foot-sandbox.md) before each installation; in B, there is no gate, and prompt discipline (“ask first”) bears the same burden alone.
3. The Tool Location and its Lifetime
The container mounts /app/data and /app/logs; in the k8s deployment (mickey), both are emptyDir. This implies the contract:
/app/data/toolsis the agent-writable tool location for the lifetime of the pod: it survives container restarts within the same pod, but every rollout wipes it. Self-installations are thus cache, not ownership — repeatable via a pinned command sequence that the manual itself provides. The manual (invance-defaults, in every Tenant) is the persistence layer for installation knowledge.HOME(/app) is more strictly forbidden than the tools directory: Pod FS dies even on container restart and additionally counts towards the Node ephemeral quota — full Node disk is a k8s eviction path. Caches should therefore be actively directed to/app/data(MAVEN_OPTS=-Dmaven.repo.local=…,PIP_CACHE_DIR=…), not to~/.m2and similar.- Workspace RootDirs are not a tool location — not due to ephemerality, but because they are project-scoped and subject to the suspend/snapshot mechanism: a 300 MB JDK in the RootDir ends up in Mongo snapshots and consumes project quota. (Deployment-side, the Workspace Root is redirected to
/app/data/workspaces, see §6 — then checkouts/venvs/node_modules live within the same declared budget as the tools.) - Per-pod, not per-Tenant:
/app/datais per pod. A second pod installs its own copy on first demand; duplication is accounted for, synchronization would be more expensive. - Access only via
exec_run: Thework_file_*tools are confined to the RootDir and deliberately cannot reach/app/data. The Exec Isolation feature (work-target.md §8, bubblewrap) also disables install paths when activated.
4. The env.sh Mechanic
/app/data/tools/env.sh is an agent-written file with export lines (PATH, JAVA_HOME, MAVEN_OPTS, possibly GH_TOKEN). The entrypoint (Phase 2b) sources it before the JVM starts:
- The exports land in the JVM’s process environment and thus in every
work_exec_runchild process — an installed tool is pod-wide onPATHafter the next container restart, without per-call PATH manipulations. - Syntax Guard: The entrypoint checks the file with
sh -nbefore sourcing it. A brokenenv.shmust not prevent Brain boot — it is skipped with a warning; the consequence is that all self-installations disappear from PATH until someone fixes the file. The manual discipline “append-only, one line per tool” is precisely the countermeasure. - Until restart, the agent sets
PATHin the call itself (exec_runallows full shell syntax, but deliberately has no env parameter). - A new pod starts with an empty tools directory: no
env.shinlining promises anything there that is no longer present.
5. Agent Responsibilities
The rules are LLM-facing and documented in the self-installed-tools manual; the design rationale is as follows:
| Rule | Why |
|---|---|
Pin versions, never latest |
Reproducibility; otherwise, each model silently loads something different |
| No parallel installations | sdkman is not concurrency-safe |
| Clean up old versions, direct caches | The volume is a budget, shared with workspaces and exec logs; Node eviction is a hard kill path |
gh-token per call, do not persist |
env.sh is in plaintext on the volume; persistent tokens only at the explicit request of the user |
Only touch /app/data/tools |
“Cleanup” beyond its own directory affects project data |
| On the workstation: ask-first | No gate before an installation on a human’s machine (Context B); in C, the foot sandbox applies |
6. k8s Measures (mickey, Deployment-Side)
The emptyDir remains deliberate — Vance treats pod death as a design case (Workspace suspend/recover from Mongo snapshots), and PVC would duplicate it and introduce Node pinning + storage class costs. The surrounding measures are adjusted (mhus-infrastructure, apps/vance/brain.yaml / configmap):
- Workspace Root to
/app/data/workspaces(EnvVANCE_WORKSPACE_ROOT→ Propertyvance.workspace.root): Checkouts/venvs/node_modules then survive container restarts without a recover dance and are within the same budget as tools and exec logs. Pod death remains the snapshot case, as intended. sizeLimiton the data-emptyDir (e.g., 10Gi): “Node disk fills up diffusely” becomes an attributable pod budget.HF_HOMEto/app/data/.cache(Env-Override): Whisper models (~1–3 GB) survive container restarts and are within the budget instead of on the Pod FS.
7. Limitations & Rejected Alternatives
- No Homebrew (in the container): Default prefix requires root, custom prefix is unsupported, would require a build toolchain that the slim image lacks, ~1 GB mass. sdkman + static binaries cover the same need with two curl calls. On the workstation, brew is naturally the tool of choice — the marker decides.
- No sdkman template in the image: Everything under
/app/datais overwritten by the volume mount — a baked copy would be invisible. The bootstrap installer (get.sdkman.io) is the one consciously accepted runtime download; after that, sdkman installs itself. - No PVC instead of emptyDir (as of today): would have introduced Node pinning, storage class discussions, and ReadWriteOnce edges, and duplicated an existing mechanism feature (Snapshot/Recover). The upgrade path remains open: the contract “Tools under
/app/data/tools, formulation against pod lifetime” survives a later PVC swap unchanged. - No second marker value for k8s: agent behavior does not differ, only expectations — and those are already formulated defensively.
- Supply chain residual risk: Runtime self-provisioning means the LLM downloads and executes binaries from the network. Mitigation: pinned releases as a manual requirement, unprivileged user without root, container boundary, opt-in bubblewrap isolation. A deterministic overall image would only be possible with the loss of self-provisioning.
- Image changes remain the exception: Every baked addition costs a redeploy of all pods. The threshold for baking in is: “needed from the first round” (gh) or “otherwise no self-provisioning is possible at all” (installer set).
8. References
- Manual:
vance-brain/src/main/resources/vance-defaults/_vance/manuals/self-installed-tools.md - Implementation:
deployment/docker/brain/Dockerfile(marker ENV, gh layer, installer set),deployment/docker/brain/docker-entrypoint.sh(Phase 2b) - Execution locations: work-target.md (CLIENT/WORK/DAEMON, §8 Isolation) · foot gate: foot-sandbox.md · Workspace RootDirs: workspace-management.md