Contained Runtimes¶
factory contained runs any factory command somewhere other than your shell — in a podman container
on your machine, or in a pod on an OpenShift cluster.
Two things make it worth using. The run happens against a pinned toolchain — a known Python, a known set of agent CLIs, a known set of build tools — rather than whatever your machine has accumulated. And it works on a copy of your project, so your working tree is never modified.
The copy is a git worktree of your repository, which means two things survive a run on purpose: the
copy itself, holding whatever the run produced, and a contained/<name> branch pointing at it.
rm prints the two commands that remove both once you are done with them.
Everything after -- is handed inward verbatim. The runtime is a place to run the factory, not
a mode of it, so the host never parses what you pass and cannot break when the CLI grows.
Read the guarantees before trusting them
contained bounds accidents and gives runs a reproducible environment. It does not confine
agent-authored code, it is not a multi-tenant boundary, and it does not replace review.
See What it does and does not protect you from.
Quick start¶
factory contained setup # pull the image, check prerequisites
factory contained verify # report what's missing, with the fix for each
factory contained -- ceo ~/code/my-project
factory contained ls # what's running
factory contained attach <name> # watch it; Ctrl-b d detaches, the run continues
factory contained sync <name> # how to get the work back
factory contained rm <name> # tear it down
You need podman (with its machine running on macOS), and inference credentials — an
ANTHROPIC_API_KEY, a Vertex configuration, or a credential profile in ~/.factory/config.toml.
Choosing a target¶
--target local |
--target k8s |
|
|---|---|---|
| Where it runs | a podman container on your machine | a pod on a Kubernetes/OpenShift cluster |
| Good for | everyday work; attaching and watching | long unattended runs; more CPU and memory than a laptop |
| Needs | podman | a namespace, and a one-time setup you apply yourself |
| Your project | a copy, bind-mounted from disk | a copy, uploaded to a volume that outlives the pod |
| Credentials | taken from your shell, and they enter the container | a Secret you create in the namespace |
| Survives a laptop closing | no | yes |
What it does and does not protect you from¶
contained exists to make runs reproducible and to keep them off your working tree. Both
targets do that well.
It is not a security sandbox, and it is worth being concrete about what that means:
- The agent's code runs with normal network access and can reach anything your machine can. Nothing restricts what it writes or fetches.
- Locally, your inference credentials are inside the container, because the agent needs them to work.
- A contained run does not make its diff safe to merge. Review the result exactly as you would review any other change.
- Neither target is built for running code you do not trust, or for sharing a machine or namespace with people you do not trust.
The cluster target is the more constrained of the two — it runs under a restricted security context with namespace-scoped permissions — but the point above still stands for both.
Command reference¶
factory contained [runtime flags] -- <any factory command>
factory contained {ls|attach|rm|sync|setup|verify|bundle|help} [name]
help prints the same text as --help, so whichever you reach for works.
Both targets
| Flag | Default | Meaning |
|---|---|---|
--target local\|k8s |
local |
Which runtime |
--division |
off | Enable the container-manufacturing plane for that target |
--name NAME |
derived | Runtime name |
--env KEY=VALUE |
— | Extra environment, repeatable |
--forward VAR |
— | Forward a named host variable, repeatable |
--image REF |
published default | Override the runtime image |
--yes |
off | Skip confirmations (rm of an active run, the secret-scan gate) |
Local only
| Flag | Meaning |
|---|---|
--mount PATH |
Additional host path bind-mounted in, repeatable |
K8s only
| Flag | Default | Meaning |
|---|---|---|
--namespace NS |
current context | Never hardcoded |
--context NAME |
your current one | Which kubeconfig context every cluster command uses |
--storage-class SC |
cluster default | Workspace PVC |
A flag used against the wrong target fails at parse time naming the target it belongs to — never silently ignored. Runtime flags go before the subcommand; anything flag-shaped after it is an error rather than a name.
Interaction examples¶
Transcripts below are from real runs, with $HOME shortened and the project renamed to
my-project throughout so nothing here reads as a required argument. They were captured
separately, so run names and ages differ between them.
Checking prerequisites¶
verify reports; it changes nothing. Every failure carries the command that fixes it.
$ factory contained verify
[FAIL] container_engine: podman is installed but its engine is not reachable: Cannot connect to Podman...
fix: podman machine start
[FAIL] runtime_image: ghcr.io/akashgit/remote-factory/factory-runtime:latest is not present locally
fix: factory contained setup # pulls ghcr.io/akashgit/remote-factory/factory-runtime:latest
or, if it is not published yet, point at one you have:
export FACTORY_CONTAINED_IMAGE=<your-image>
[FAIL] inference: no inference configuration found: CLAUDE_CODE_USE_VERTEX is unset,
ANTHROPIC_API_KEY is unset, and ~/.factory/config.toml defines no credential profiles
fix: export ANTHROPIC_API_KEY=... and re-run with --forward ANTHROPIC_API_KEY, or
configure Vertex (...), or add a [credentials.<name>] section to ~/.factory/config.toml
3 check(s) failed. `factory contained setup` can fix container_engine, runtime_image; the rest
need the fix shown above each one.
That is what a first run looks like on a machine with nothing set up. setup fixes the first two;
the third is yours, because the factory never handles credential material.
Inference is always reported by shape — which backend, which model, which variable or file supplied it — and never by printing material:
setup runs as a numbered sequence, so it is always clear which step you are on and which one
stalled. On a terminal the step rules, the [ ok ] / [FAIL] marks and every resolved value are
coloured; piped or redirected, the same output is plain text.
$ factory contained --target local setup
━━ 1/3 Container engine ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
The podman engine is not reachable. Starting the podman machine...
━━ 2/3 Runtime image ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Image already present: ghcr.io/akashgit/remote-factory/factory-runtime:latest
━━ 3/3 Result ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[ ok ] container_engine: podman reachable (5.7.1, rootful)
[ ok ] runtime_image: ghcr.io/akashgit/remote-factory/factory-runtime:latest present locally
[ ok ] inference: Vertex, project my-project in us-east5, model <unset — pass --model in the
payload>, credential from Application Default Credentials at ~/.config/gcloud
All checks passed. Start a run with `factory contained -- ceo <path>`.
Colour is navigation, not decoration, so it obeys the conventions you already have configured:
NO_COLOR turns it off, FORCE_COLOR turns it on through a pipe, and TERM=dumb is respected.
If the image cannot be pulled
The runtime image is published by CI. If the pull fails, setup prints two ways forward: point
FACTORY_CONTAINED_IMAGE at an image you already have, or build one from a checkout of the
repository — the Containerfile ships in git, not in the installed package.
Run without --target, and at a terminal, setup asks which runtime you are preparing first:
$ factory contained setup
━━ What are you setting up? ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Pass --target local or --target k8s to skip this question.
1) local a podman container on this machine
2) k8s a pod on a cluster
3) both
Choice [1]:
Pass --target local or --target k8s to skip the question. setup is idempotent — re-running
changes nothing that is already correct, and it is the supported way to repair a partial setup.
Starting a run¶
The runtime's identifier is printed first, before any long-running work. A run whose name you cannot see is a run you cannot manage.
$ factory contained --name my-run -- backlog-list ~/code/my-project
Warning: no inference credentials are configured, so every agent call in this run will fail.
Set one of these before running, and pass it inward:
export ANTHROPIC_API_KEY=... then add: --forward ANTHROPIC_API_KEY
Run `factory contained verify` to check.
Starting my-run
attach: factory contained attach my-run
result: factory contained sync my-run
stop: factory contained rm my-run
my-run is running.
That is the whole output. The command returns as soon as the run is going; the run itself continues
in tmux inside the container. Set FACTORY_LOG_LEVEL=debug if you want to see every command the
runtime issued.
Watching, detaching, coming back¶
$ factory contained ls
NAME TARGET PROJECT AGE STATE
my-run local e06e95065606 1s running
$ factory contained attach my-run
ls covers both targets, but it only asks the cluster once you have actually used one — otherwise
a laptop that has only ever run locally would wait on a network timeout and then be told about a
cluster it never set up. --target k8s ls always asks.
That drops you into the live session. Ctrl-b d detaches and leaves the run going — the tmux
prefix, because the run lives in tmux precisely so that detaching is safe.
Typing exit is safe too. It ends the shell inside the session and returns you to your own
terminal; the session and everything it printed stay, and attaching again gives you a fresh shell in
the same window. ls shows such a run as finished rather than running, because the container
deliberately outlives the run inside it.
Getting the work back¶
Nothing is ever merged for you.
$ factory contained sync my-run
my-run: the workspace is already on this machine — a bind mount, not a transfer.
Work is on branch contained/my-run in ~/.factory-contained/my-run/my-project.
Review: git -C ~/.factory-contained/my-run/my-project status && git -C ... diff
Merge: git -C ~/code/my-project merge contained/my-run
Tearing down¶
$ factory contained rm my-run
my-run: deleted. Your work is kept — it is not removed with the runtime.
Work is on branch contained/my-run in ~/.factory-contained/my-run/my-project.
Review: git -C ~/.factory-contained/my-run/my-project status && git -C ... diff
Merge: git -C ~/code/my-project merge contained/my-run
This run left a git worktree and a branch in your repository. Remove them with:
git -C ~/code/my-project worktree remove ~/.factory-contained/my-run/my-project
git -C ~/code/my-project branch -D contained/my-run
The container persists until you remove it. Nothing is auto-reaped, because a failed run is exactly when its state is worth reading. A launch that fails before the container exists cleans its own workspace up, so only runs that actually started leave anything behind.
When the workspace is wrong¶
Five assertions run between provisioning and the first agent call. A failure aborts before any tokens are spent, names the likely cause, and leaves the runtime up so you can look:
$ factory contained --name my-run -- ceo ~/code/my-project
contained: step 'assert:git_usable' failed
The workspace is not a usable git repository inside the runtime.
Most likely the repository this project belongs to was not mounted — a git worktree's .git is a
file pointing at a directory elsewhere.
Try: factory contained --mount <path-to-that-repository> -- <your command>
The container is still there for inspection:
podman exec -it my-run sh
factory contained rm my-run
This run left a git worktree and a branch in your repository. Remove them with:
git -C ~/code/my-project worktree remove ~/.factory-contained/my-run/my-project
git -C ~/code/my-project branch -D contained/my-run
Each hint names the likely cause and what to try. The container is left running so you can look inside it before removing it.
Composing without provisioning¶
FACTORY_CONTAINED_DRY_RUN=1 prints the exact commands the real path would run, and provisions
nothing:
$ FACTORY_CONTAINED_DRY_RUN=1 factory contained -- study ~/code/my-project
DRY RUN — my-run (ghcr.io/…/factory-runtime:latest); nothing is provisioned.
[create] podman run -d --init --name my-run --label factory.contained=true …
[assert:project_present] podman exec my-run sh -lc '[ -d "…" ] && [ -n "$(ls -A "…")" ]'
[assert:git_usable] podman exec my-run sh -lc 'git -C "…" status --porcelain >/dev/null 2>&1'
[assert:factory_state] podman exec my-run test -f …/.factory/config.json
[assert:writable] podman exec my-run sh -lc 'touch "…/.factory-write-probe" && rm -f …'
[assert:content_hash] podman exec my-run sh -lc 'sha256sum "…" | grep -q "^<digest> "'
[run] podman exec my-run sh -lc 'tmux new-session -d -s factory -c … '
[…the run line is ~45 lines: it embeds the Claude Code state seeding verbatim…]
Which assertions appear depends on what your project actually has: factory_state only when the
project has a .factory/config.json, git_usable only when it is a git repository.
The [run] line really is that long, and it will look like line noise. Dry-run's contract is to
print the same commands the real path runs, so it is not trimmed — a tidier rendering could drift
from what actually executes, which would defeat the point of previewing.
Secret-looking values are redacted anywhere a command is printed:
$ FACTORY_CONTAINED_DRY_RUN=1 factory contained --forward GH_TOKEN -- study ~/code/my-project
… --env GH_TOKEN=<redacted> …
The local division¶
--division gives the contained agent your host's podman engine, so it can build an image, run
it, read the failure and iterate.
Builds happen on your machine rather than inside the container: the container has no container engine of its own, and nesting one inside it is not workable on macOS. That is why this is a separate flag rather than something always on.
$ factory contained --division --name buildcycle -- ceo ~/code/my-project --focus "add a Containerfile"
┌─ Container builds enabled (--division) ───────────────────────────────────────
│ Started podman-mcp-server so the agent can build and run container images.
│ The run reaches it at http://host.containers.internal:8430/mcp
│
│ It listens on 0.0.0.0:8430 — every network interface, not just this
│ machine — and it has no authentication. For as long as the run lasts, anyone
│ who can reach that port can build and run containers as you.
│
│ Avoid --division on untrusted networks.
│ It stops when the run is removed:
│ factory contained rm buildcycle
└───────────────────────────────────────────────────────────────────────────────
Starting buildcycle
attach: factory contained attach buildcycle
result: factory contained sync buildcycle
stop: factory contained rm buildcycle
buildcycle is running.
The endpoint lives as long as the run, not as long as the launching command — the launch returns
immediately while the run continues for minutes or hours. factory contained rm stops it.
It cannot be bound to loopback instead: the container reaches your machine through a gateway address rather than through localhost, so a loopback bind would make the build tools unreachable rather than make them safer.
FACTORY_CONTAINED_DRY_RUN=1 shows the same banner, marked as not started, so you can see what
--division would do before doing it.
The agent gets the podman tool surface plus a brief telling it these are capabilities it already has. Asked to name its tools, it answers with them rather than proposing to build a CLI wrapper:
$ factory contained --division -- agent builder \
--task "List the container tools available to you. Do not write code." --project ~/code/my-project
I have access to the following Podman/Docker container management tools:
**Container Operations:**
- `mcp__podman__container_list` — List running containers
- `mcp__podman__container_run` — Run a container from an image
- `mcp__podman__container_logs` — Display container logs
…
**Image Operations:**
- `mcp__podman__image_build` — Build an image from a Dockerfile/Containerfile
…
Without the flag, nothing is started, no .mcp.json is written, and the agent has no container
tools. The division is genuinely opt-in.
Requires npx on PATH.
The cluster target¶
--target k8s runs the factory unattended on hardware your laptop is not: real CPU, real memory,
amd64, and a workspace that survives the pod.
One-time namespace setup¶
bundle prints plain namespace-scoped YAML and never applies it. setup asks which namespace to
prepare, checks what is already there, then walks you through only the objects that are missing
or wrong — one at a time, each explained — and applies what you accept with your own
credentials:
$ factory contained --target k8s setup
━━ 1/3 Cluster and namespace ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Clusters in your kubeconfig:
1) 'default/api-my-cluster-example-com:443/you@example.com' (current)
https://api.my-cluster.example.com:443
2) 'factory/api-lab-cluster:443/you'
https://api.lab-cluster.example.com:443
Which cluster? [1] 2
This is where the factory's ServiceAccount, Role, RoleBinding and workspace
PVC will live. If it does not exist yet, you will be offered the chance to
create it.
Cluster: https://api.my-cluster.example.com:443
User: you@example.com
Context: default/api-my-cluster-example-com:443/you@example.com
Namespace: 'default' (the default below)
Namespace to prepare [default] factory-contained
Namespace 'factory-contained' does not exist on this cluster.
Create namespace 'factory-contained' now? [y]es [n]o (y/N): y
$ oc new-project factory-contained
Created factory-contained.
`oc new-project` also made it your current project.
━━ 2/3 Review and apply ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Comparing 5 object(s) against namespace 'factory-contained' on
'https://api.my-cluster.example.com:443':
[ ok ] serviceaccount/factory already present and matches what the factory needs
[diff] role/factory-runtime present, but not what the factory needs
[new ] rolebinding/factory-runtime not in this namespace — it would be created
[new ] rolebinding/factory-scc not in this namespace — it would be created
[new ] pvc/factory-workspace not in this namespace — it would be created
1 already correct and will be skipped; 4 need(s) your decision.
── 1 of 4 · role/factory-runtime (present, but not what the factory needs) ───
What that identity may do, and the whole of it: create, watch and delete
pods in this namespace, and read their logs. That is what a run needs to
launch a validation pod and see why it failed. `pods/exec` is absent on
purpose — the build sidecar is a boundary only because the agent cannot
exec into it.
What would change in factory-contained:
rules:
- verbs: ["get"]
+ verbs: ["create", "get", "list", "watch", "delete"]
Apply this? (1 of 4) [y]es [n]o [a]ll remaining [q]uit (Enter or Esc = skip/stop): y
role.rbac.authorization.k8s.io/factory-runtime configured
── 2 of 4 · rolebinding/factory-runtime (would be created) ───────────────────
Grants the Role above to the ServiceAccount above. Without it the Role
exists and applies to nobody, and the run fails on its first cluster call.
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
…
Apply this? (2 of 4) [y]es [n]o [a]ll remaining [q]uit (Enter or Esc = skip/stop): a
Applying this and the 2 after it.
rolebinding.rbac.authorization.k8s.io/factory-runtime created
rolebinding.rbac.authorization.k8s.io/factory-scc created
persistentvolumeclaim/factory-workspace created
The credentials Secret is yours to create — the factory never handles the material:
oc create secret generic factory-credentials -n factory-contained \
--from-literal=ANTHROPIC_API_KEY=...
Six details that are deliberate.
The cluster is asked too, not just the namespace. A kubeconfig usually holds several, and oc
config use-context is the only way most people know to move between them — so picking the wrong
one meant Ctrl-C, a context switch and a restart. Whatever you choose is applied as --context on
every command this invocation issues, including the applies and the diffs; it does not rewrite
your kubeconfig, because deciding where this run goes must not silently change where your next
unrelated oc get pods goes. At the end, if you prepared a cluster that is not your default, you
are offered the switch and given the exact command either way. --context NAME skips the question.
The namespace is asked, not assumed — --namespace skips the question, and without it the
current context supplies the default rather than the answer, so a shared default never quietly
acquires a ServiceAccount, a Role and a 10Gi PVC. It is then checked, including when you passed
it explicitly: a typo would otherwise surface as five separate NotFound errors from the apply. If
it is not there you are offered the chance to create it, via oc new-project rather than create
namespace, because a regular user is usually denied the second and permitted the first. A
namespace you are simply not allowed to read — routine on OpenShift for a project you own — is
reported as unconfirmed rather than treated as missing.
The cluster is named alongside it. A namespace on its own identifies nothing — default exists
on every cluster you have ever logged into — so the API server URL is the field that actually
answers "am I about to apply RBAC to the right place?". Every namespace and server is printed
quoted and highlighted, because "in namespace default" gives a reader no way to tell the name from
the sentence. Only names are read from your kubeconfig: the context, the user's name, the server
URL, the namespace. Nothing from its users section, which is where credential material lives.
The current state comes first. Each object is checked against the namespace before anything is
asked, and the summary covers all of them including the ones already correct — "4 of 5 are already
there" is the most useful thing to know before deciding whether this is about to do something
drastic. Comparison is oc diff, done server-side, so a field the cluster defaults in does not
read as a change you are about to make.
Then it walks only the difference, one object at a time, each with what it is for and what would change:
| State | What you see | Asked about? |
|---|---|---|
| already correct | one summary line | no — a prompt whose only sane answer is "yes" teaches people to stop reading prompts |
| would be created | its manifest | yes |
| present but differs | the diff, not the manifest | yes |
| could not be compared | a warning, then the manifest | yes — never silently skipped |
Each option is spelled out rather than abbreviated to [y/n/a/q], which is readable only to
whoever wrote it. y applies one, n skips it, a applies everything remaining, and q or
Escape stops. A bare Enter skips, because the default has to be the answer that changes nothing;
anything unrecognised re-asks and never counts as yes. Where the terminal allows it these are
single keypresses — no Enter — which is also what makes Escape work at all, since a line-buffered
prompt can only ever see it as the ^[ characters it inserts.
Escape backs out of any prompt in the flow, not just this one — the cluster chooser and the
namespace prompt included, and the namespace prompt is a typed line, which is why it is read
character by character rather than with input(). Ctrl-C exits with a message and status 130
rather than a stack trace: changing your mind at question three is ordinary, not a crash.
Finally, each object is applied the moment you accept it, not batched until the end. You see
role.rbac.../factory-runtime configured before deciding the next one, and stopping halfway is
reported honestly:
Apply this? (2 of 4) [y]es [n]o [a]ll remaining [q]uit (Enter or Esc = skip/stop): q
Stopped. 1 object(s) were applied before you stopped and remain applied; the rest were not.
That sentence is the whole reason for applying per object: batching would have said "nothing was
applied" to someone who had already said yes once. A skipped or unapplied object stays as it is,
and verify at the end reports it — nothing goes quiet. A single object failing to apply names
itself and does not stop the walk, since the rest may still be worth doing.
There is no second, blanket "are you sure?": every object was confirmed a moment earlier, and a
prompt on top of that is the friction that teaches people to hit y without reading. --yes
applies everything pending without walking, for automation.
Then verify checks every object, every verb the ServiceAccount needs, the Secret's keys (never
its values), and that inference is reachable from a pod inside the namespace. Results print as
each one lands, not at the end — several are a cluster round trip and the in-cluster inference
probe launches a pod and waits on it, so a step that stayed silent until the last check finished
was reported as a hang:
$ factory contained --target k8s --namespace factory-contained verify
[ ok ] cluster_cli: oc, context factory-contained/api-…:443/you@example.com,
server https://api.my-cluster.example.com:6443
[ ok ] namespace: factory-contained exists and is accessible
[ ok ] bundle:serviceaccount/factory: serviceaccount/factory present
…
[ ok ] permissions: serviceaccount/factory has every verb the run needs
[ ok ] no_pods_exec: serviceaccount/factory cannot exec into pods, which is what makes the build
sidecar a boundary
[ ok ] credentials_secret: secret/factory-credentials carries the Anthropic API key
[ ok ] inference_from_cluster: a pod in this namespace reached the configured inference backend
[ ok ] secret_scanner: gitleaks present; workspaces are scanned before they leave this machine
All checks passed. Start a run with `factory contained --target k8s --namespace factory-contained -- ceo <path>`.
The inference check is the slow one: it creates a short-lived pod, with the same image and Secret a real run uses, and asks it to make one request — because a host-side check proves nothing about the pod's egress. It is announced before it starts, and skipped entirely when the credentials Secret is missing, since the probe pod mounts that Secret and could only spend its 180-second timeout rediscovering what the check above already said. That is the state a freshly prepared namespace is in, because creating the Secret is deliberately left to you.
Before setup, the same command lists what is missing with the command that restores each — e.g.
factory contained --namespace factory-contained bundle | oc apply -f -.
Running¶
$ factory contained --target k8s --namespace factory-contained -- run ~/code/my-project --loop
k8srun
attach: factory contained --target k8s attach k8srun
result: factory contained --target k8s sync k8srun
logs: oc logs -f k8srun -n factory-contained -c factory
The workspace is packed into one tarball, streamed into an initContainer that is waiting for it, and
unpacked onto the PVC before the factory container starts. oc cp of a directory is one API round
trip per file, which is painfully slow on a repository.
The secret scan¶
Nothing leaves your machine unscanned. Gitleaks runs over the workspace before the upload:
$ factory contained --target k8s -- study ~/code/my-project
gitleaks: 1 finding(s)
.env:1 [github-pat] Uncovered a GitHub Personal Access Token, potentially leading to
unauthorized repository access and sensitive content exposure.
This workspace is about to be copied onto cluster storage. Anything above goes with it.
Refusing to upload without confirmation. Re-run with --yes to proceed non-interactively.
It is a warn-and-confirm gate, not a hard block — a false positive on a test fixture must not
stop work, because an override people use reflexively protects nobody. --yes proceeds, and says so
rather than passing silently. If gitleaks is not installed, the upload warns that it is unscanned
rather than quietly going ahead.
Getting the work back, and tearing down¶
$ factory contained --target k8s sync k8srun
k8srun: workspace fetched to ~/.factory-contained/k8srun/workspace.tar.gz.
Review: tar tzf ~/.factory-contained/k8srun/workspace.tar.gz
Unpack: mkdir -p <dir> && tar xzf ~/.factory-contained/k8srun/workspace.tar.gz -C <dir>
Nothing is merged automatically.
$ factory contained --target k8s rm k8srun
k8srun: pod deleted.
The workspace is still on PVC factory-workspace in factory-contained. Fetch it with
`factory contained --target k8s sync k8srun` before deleting the claim.
The PVC is deliberately left alone: it may hold the only copy of a long run's work.
The cluster division¶
--target k8s --division is OpenShift only, refused at launch by API presence rather than by
whether oc happens to be installed. Builds go through OpenShift Build objects, submitted by a
sidecar container that is the only holder of oc and the ServiceAccount token — the agent's
container has neither, and cannot exec into the sidecar because the Role excludes pods/exec.
verify asserts that verb's absence; it is the one check that fails when something succeeds.
The agent gets one tool for building — start_build(dockerfile, tag) — plus namespace-scoped
cluster tools for launching validation pods and reading logs.
Checks the runtime runs for you¶
Before the first agent call, the runtime asserts that the workspace it is about to use is the one
you meant — that it is present and non-empty, that git works in it, that .factory/ arrived if your
project has one, that it is writable, and that a file's contents match the copy on your machine.
Each of these can fail silently otherwise: a read-only workspace looks like an agent whose edits keep vanishing, and a stale copy produces a plausible result from the wrong code. A failed check stops the run before any tokens are spent and leaves the container up so you can look inside it.
Environment¶
| Variable | Purpose |
|---|---|
FACTORY_CONTAINED_IMAGE |
Override the runtime image |
FACTORY_CONTAINED_SIDECAR_IMAGE |
Override the k8s build sidecar's oc image |
FACTORY_CONTAINED_HOME |
Where workspace copies live (default ~/.factory-contained) |
FACTORY_CONTAINED_DRY_RUN=1 |
Print what would run; provision nothing |
FACTORY_LOG_LEVEL=debug |
Show every command the runtime issues (quiet by default) |
Nothing crosses into the runtime that you did not ask for. Variables starting with FACTORY_
go in, along with whatever --forward names and the variables your inference backend needs — and
nothing else. Your ~/.factory/ is mounted read-write, so config, credential profiles, the project
registry and evolved playbooks work exactly as they do outside. Anything else you want in there —
~/.claude/projects/, GH_TOKEN, FACTORY_MANAGED_DIRS, FACTORY_VAULT_PATH — you pass explicitly
with --mount or --forward.
Troubleshooting¶
"podman is installed but its engine is not reachable" — on macOS the machine stops quietly.
podman machine start, or factory contained setup, which does it for you.
"The workspace is read-only inside the runtime" — the container runs as a user that does not own
your files. Check that the project is owned by you, and that factory contained verify is green.
"could not read ... from inside a container" — usually the podman machine does not share that
path. On macOS it shares your home directory; a project elsewhere is not mounted at all rather than
mounted empty. Move it under your home directory, or add the path with podman machine set --volume
and restart the machine. The launch warns about this before it happens.
"is not a path the podman machine shares" — same cause, caught at launch. The message lists the paths that are shared.
A wall of output instead of three lines — that is FACTORY_LOG_LEVEL=debug. Unset it.
"container 'x' already exists" — a previous run left it. Attach to it, rm it, or pass --name.
A container that is no longer running is reaped automatically and the run retried once.
"is already running a session — this is the same run, not a new one" (k8s) — the pod is mid-run.
Attach, or rm and start again.
"the division port 8430 is already held by the run 'x'" — one port, one server. Finish or remove
that run first, or run this one without --division.
Vertex 429s on every call — pass an explicit --model. MAX_THINKING_TOKENS=0 is pinned for you.
Implementation¶
| Concern | Module |
|---|---|
| All podman CLI knowledge | factory/podman.py |
| All cluster CLI knowledge | factory/contained/k8s.py |
| Workspace copy | factory/contained/workspace.py |
| Provenance assertions | factory/contained/provenance.py |
| Container identity probe | factory/contained/identity.py |
| Credential shape | factory/contained/credentials.py |
| Local division | factory/contained/division.py |
| Pre-answering Claude Code's first-run prompts | factory/contained/claude_state.py |
| Cluster division | factory/contained/k8s_division.py |
| Prereq bundle | factory/contained/bundle.py |
| Object-by-object review | factory/contained/k8s_review.py |
| Secret scan | factory/contained/secrets.py |
| Terminal colour and wizard steps | factory/contained/style.py |
| CLI front door | factory/cli/contained.py |
| Reading the command line | factory/cli/contained_args.py |
| One local container | factory/cli/contained_local.py |
| One cluster pod | factory/cli/contained_k8s.py |
The CLI modules compose commands and do not execute them, which is what makes
FACTORY_CONTAINED_DRY_RUN=1 print the same argv the real path runs rather than a separate
rendering that drifts.
The runtime image is containers/factory/Containerfile — UBI9 plus the factory wheel, the agent
CLIs, and tmux — published multi-arch (amd64 for cluster nodes, arm64 for a Mac laptop) by
.github/workflows/runtime-image.yml. factory contained setup pulls it; it does not build.
It publishes on three events. A push to main moves :latest; a published release builds that
release's commit and publishes :<release tag>, moving :latest too unless the release is a
prerelease; workflow_dispatch publishes whatever tag you name. Every event also tags the short
SHA. The release trigger is the one that has to be automatic — setup pulls a published image and
does not build, so a release whose image was never built leaves a new user's first command failing
on a manifest that does not exist. Nightlies are prereleases and therefore never move :latest.