Add tools to the Sandbox
Put the programs that your Agents and command Effects use into the Sandbox Image.
A Workspace runs its body in a Sandbox: a pod of its own. The Sandbox Image is the image of the pod's harness container. The Agent's Working tools run in that container, and so do the command Effects and the shell that you get with kubectl exec. Thus a program that is in the image is a program that they can run.
Use this table to choose:
| You want | Do this |
|---|---|
| The same tools for each Workspace | Change images/default/Dockerfile. |
| Different tools for the Workspaces of one file | Put a docker context beside the workflow module, and name it in the image option. |
| An image that you build and push yourself | Write its registry ref in the image option. |
| A service that runs beside the Agent | Name its image in the user option. It runs in the User Container. |
Add a tool to the default image
jr2 init writes images/default/Dockerfile. Each Workspace that names no image in workspace() uses it. This example adds pnpm to the file that jr2 init wrote:
FROM node:24-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Your agents' toolchain goes here — compilers, CLIs, language servers, dotfiles.
# pnpm on the system PATH, at a fixed version, so that each Sandbox gets the same pnpm.
RUN npm install -g pnpm@10.9.0The example does not show the comments at the start of the file. Keep them in your file.
Install a tool at a fixed version, as the example does. Then each build of the image gets the same version.
Run jr2 up. It builds the image. Then it loads the image into kind, or pushes it to the registry of jr2.config.ts:
jr2 up…
sandbox image "default": building jr2-sandbox-my-instance-default:fce23ddc3cce-amd64
no USER declared — the pod's uid-1000 fallback applies
kind load → cluster "jr2"
…- The tag is the hash of the files in the folder, then the architecture of the nodes. Your hash is different. A change to a file in the folder changes it. The architecture is such as
amd64. - On kind,
jr2 uploads the image into the cluster. The next line names the cluster. - When no file in the folder changed,
jr2 updoes not build the image again. The line then ends with(fresh — build skipped; --force to rebuild anyway). jr2 upbuilds the Sandbox Images only when a registered Machine has aworkspace(). Otherwise it printssandbox images: skipped (no registered Machine composes a Sandbox).
A Sandbox that runs now keeps its image. The next Sandbox gets the new image. jr2 up removes the old image at the end of a later converge, when no Sandbox uses it. On a kind node, it keeps the old image for one more converge. It does not remove an image from a registry. jr2 gc does the same work when you ask for it (see Clean up).
To see the tool, open a shell in a Sandbox (see Look inside a Sandbox), and run it:
pnpm --version10.9.0What the image must have
The image is yours. jr2 adds no layer to it, and does not read the Dockerfile. When the pod starts, jr2 mounts its own runtime at /opt/jr2, and starts the Harness from there. Thus:
- The
ENTRYPOINTand theCMDof the image do not run. To run a process all the time, use a User Container (see Run a service beside the Agent). - jr2 adds
/opt/jr2/binat the end ofPATH. Thus a tool of the same name in your image comes first. - The
USERand theHOMEof the image stay as you set them. When an image thatjr2 upbuilt sets noUSER, the pod runs as uid 1000, withHOME=/home/jr2.
The image must have these, or the Sandbox does not start:
- A glibc base no older than the glibc of jr2's node, such as a current Debian or Ubuntu. An Alpine base has musl, and jr2's runtime does not run on it.
giton the systemPATH. The Harness clones, makes the Worktrees and commits with it.- A
HOMEthat the image'sUSERcan write. - A
USERthat is a number and is not0, such asUSER 1000. An image thatjr2 upbuilds can also have noUSER. Theharnesscontainer does not run as root. - Nothing at
/opt/jr2or/work, and nothing that listens on port8080.
The image does not need a git author. When the image sets none, a commit in the harness container has the author jr2 Agent <agent@jr2.invalid>. jr2 puts this default at git's system level, under the image's own /etc/gitconfig, so each git author that the image sets is used in its place: in /etc/gitconfig, in the HOME of its USER, or in GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL. When the image sets GIT_CONFIG_SYSTEM or GIT_CONFIG_NOSYSTEM, or its git reads another system file than /etc/gitconfig, jr2 does not set the default.
Before the Harness starts, a preflight step in the pod checks the image. When the check fails, the run faults. Read the log of the step with kubectl -n <namespace> logs <sandbox> -c preflight. <namespace> is the namespace of the Instance, such as my-instance. Look inside a Sandbox tells how to find <sandbox>.
Give one Workflow its own image
Put a docker context in a folder beside the workflow module. Name the folder in the image option of workspace(). In this example, workflows/pytest.ts runs the tests of a Python Repo, with the image in workflows/python/:
# A Sandbox Image for a Python project. The build context is this folder.
FROM python:3.13-slim
# git is part of the floor: the Harness clones, makes the Worktrees and commits with it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# The tools of the project.
RUN pip install --no-cache-dir pytest ruffimport { z } from "zod";
import { command, fail, jr2Setup, workspace, type Workspaced } from "@jr2/orchestrator";
const runInput = z.object({ branch: z.string() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
const body = jr2Setup({
types: {} as { input: BodyInput },
actors: {
// python and pytest come from the Sandbox Image in ./python.
tests: command(["python", "-m", "pytest"], { timeout: "15m" }),
fail: fail(),
},
}).createMachine({
id: "pytest",
on: { "workspace.lost": ".lost" },
initial: "testing",
states: {
testing: {
invoke: {
src: "tests",
onDone: [
{
guard: ({ event }) => event.output.outcome === "exited" && event.output.code === 0,
target: "passed",
},
{ target: "failed" },
],
},
},
passed: { type: "final" },
// Not final states: each one fails the run, and the run ends `failed`.
failed: { invoke: { src: "fail", input: { reason: "pytest did not pass" } } },
lost: { invoke: { src: "fail", input: { reason: "the Sandbox was lost" } } },
},
});
export const machine = workspace(body, {
input: runInput,
// The docker context in the folder beside this file. jr2 up builds it.
image: import.meta.resolve("./python"),
repos: { app: "https://github.com/you/app.git" },
spec: ({ input }) => ({ branch: input.branch }),
});import.meta.resolve("./python")gives afile:URL to the folder. The folder is the build context. ACOPYin the Dockerfile reads from it.- jr2 reads only the
.tsfiles inworkflows/as Workflows. A folder there is not a Workflow. - An Agent in the body gets the same tools. Its Working tools run in the same container as the
command. jr2 upfinds the folder from the registered Machines, and builds it. Its line issandbox image "python": building jr2-sandbox-my-instance-python:<hash>-<arch>. The name of the folder is the name in the tag.
A Machine that you register from a package, such as task from @jr2/machines, takes an image from customize(): customize(task, { image }). The value has the same two forms, and it replaces the image of the Machine's workspace(). See Bind a shipped Machine's Open parts.
A Machine that you publish as an npm package carries its docker context in the package. See Publish a Machine as an npm package.
Use an image from a registry
When you build and push the image yourself, write its ref in place of the file: URL: image: "registry.example.com/python-sandbox:1.0". A value that does not start with file: is a registry ref.
- jr2 does not build the image, does not read it, and does not remove it.
- The nodes of the cluster pull it. jr2 gives the pod no pull credential.
- The image must be for the architecture of the nodes.
- The image must have each item in What the image must have. jr2 does not supply uid 1000 for a registry image: an image with no
USERruns as root, and the pod does not start. - For each change, push a new tag. Then change the
imagevalue to the new tag. A node that has a tag does not pull it again, unless the tag islatest.
Run a service beside the Agent
The User Container is a third container in the Sandbox pod. Use it for a process that must run without an Agent, such as an sshd for a person. This image gives a person a shell beside the Agent:
# A User Container: a shell for a person, beside the Agent, on the same Worktrees.
FROM alpine:3.21
RUN apk add --no-cache git
# The Worktrees under /work belong to a different user. Tell git to trust them.
RUN git config --system --add safe.directory '*'
# jr2 does not replace this command. It must keep running: when it ends, the pod is not ready.
CMD ["sh", "-c", "while true; do sleep 3600; done"]Save it as workflows/shell/Dockerfile. Name it in the workspace() options with user: import.meta.resolve("./shell"). user takes the same two forms as image, and jr2 up builds a file: context in the same way. Open the shell with kubectl -n <namespace> exec -it <sandbox> -c user -- sh.
The User Container is not a second Sandbox Image:
- Its own
ENTRYPOINTandCMDrun. Its process must keep running: when it ends, the pod is not ready. - jr2 gives it
/work, which it can write, and/reposand/opt/jr2, which it can only read. jr2 gives it no other mount, no environment variable and no command. - It has no floor: an Alpine base is correct here. It runs as the
USERof its image, and root is permitted. - Its user can be different from the user that owns the Worktrees, for example root. Thus an image that runs git needs
git config --system --add safe.directory '*', as the example has. - The pod has one network. The User Container cannot listen on the ports of jr2's containers:
8080,8082and15021, and8081and15001on loopback. - To give it a part of the Size, write
user: { image, resources }. See Size a Workspace.
A push, an http Effect whose body is a Worktree file, and a command that names an Effect secret run in a different container, the Sandbox Effector. By default it uses the Sandbox Image. The effector option gives it an image of its own. See Run a command or call a URL with an Effect and Push and sign a branch.
Rules
workspace()refuses animagethat is empty or not a string:workspace(): `image` must be a non-empty string (got <value>) — either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref (ADR-0037).workspace()andcustomize()refuse auserthat is not an image or{ image, resources? }.<where>isworkspace()orcustomize():<where>: `user` must be a non-empty string (an image), or { image, resources? } (got <value>) — either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref (ADR-0037, ADR-0060).and<where>: user.<key> is not accepted — the User Container is { image, resources? } (ADR-0005, ADR-0060).customize()refusesimageoruserfor a Machine that has noworkspace():customize(): `image`/`user` say what a SANDBOX is made of, how big it is and which Repos it attaches, and machine "<id>" composes none — only a workspace() wrapper carries those seats, that Size and those slots (ADR-0049, ADR-0037, ADR-0051, ADR-0060).jr2 uprefuses afile:URL to a folder that does not exist.<path>is the folder:the Sandbox Image context <path> cannot be read (<code>) — a `workspace()` names its image with `import.meta.resolve("./<dir>")`, so that directory must exist beside the Machine's own module (ADR-0037/0049).jr2 uprefuses animages/defaultfolder that has noDockerfile:the Instance's `images/default` has no Dockerfile (<path>) — that directory IS a build context (ADR-0037), and it is the Sandbox Image every `workspace()` that names none falls back to.- An image that
jr2 upbuilt, with aUSERthat is not a number or is0, faults the run before the pod is made.<image>is theimagevalue, ordefault:the Sandbox Image "<image>" declares `USER <user>`, which cannot run a jr2 seat: every jr2-owned container is `runAsNonRoot` with no `runAsUser`, so the kubelet needs a NUMERIC non-zero uid it can check without reading the image (ADR-0005). Change the Dockerfile's last `USER` to that uid (e.g. `USER 1000`, or drop the line entirely and jr2 supplies uid 1000 with a writable HOME — ADR-0037), then re-run `jr2 up`. - A registry image that runs as root faults the run. The text starts with
Sandbox "<sandbox>" cannot start: its image runs as ROOT, and every jr2-owned seat is hardened with runAsNonRoot (ADR-0005). - An image that does not have the floor stops the
preflightstep. Its log says:jr2: this Sandbox Image does not meet the floor (ADR-0037): a glibc base no older than jr2's node (musl is out entirely), git on the system PATH, a writable HOME for the image's USER, and /opt/jr2 + /work + :8080 unclaimed. jr2 vendors the rest.The run faults with a text that starts withSandbox "<sandbox>" never reached Ready (last phase: <phase>) — if its Sandbox Image is new, check the preflight: `kubectl logs <sandbox> -c preflight`. - The Orchestrator refuses a
file:image that the lastjr2 updid not build. Runjr2 upagain:no Sandbox Image for <url> (context digest <digest>) — this instance's last converge built <n> image(s), none of them this one. Re-run `jr2 up`: it walks the registered Machines and builds every `file:` context they carry (ADR-0037/0049).
Read next
- Workspaces: the pod, the Sandbox Image and the User Container.
- Converge: how
jr2 upbuilds and loads the images. - Look inside a Sandbox
- Reference:
workspace,SandboxOptions,UserContainer,jr2 up - ADR-0037: the Instance builds its Sandbox Images, and jr2 injects the Harness.
- ADR-0005: the containers of the Sandbox pod.
- ADR-0049: a Machine carries its images.