A sandbox is a machine Keera lends out for one task or one working day.
To use one, see Sandboxes. This page explains how they work and why.
The rest of Keera controls the requests an editor sends. It cannot see what an agent does with the answer on a laptop. A sandbox moves that work into the cluster.
Egress can be limited to the gateway and a few internal hosts, so a script the agent writes to upload the repository fails. How far this holds depends on the driver and the cluster; see The security boundary.
The API key never lands on a laptop. Each sandbox gets its own key at
creation, scoped to whoever asked for it. The key expires with the sandbox,
moves with it when the sandbox is extended, and is revoked when it ends or
fails. The sandbox boots already pointed at the gateway, so
keera connect is not needed inside.
A session is known, not guessed. An agent sandbox is exactly one task, so it names its own session instead of leaving the gateway to infer it (sessions.md).
| Engineer | Agent | |
|---|---|---|
| Default lifetime | the class's; 4h if it sets none | the class's; 4h if it sets none |
| When it expires | suspended, key revoked, resumable | terminated |
| When it is idle | suspended if KEERA_SANDBOX_IDLE_SUSPEND is set |
never - it has nothing to wait for |
| Session header | not set | X-Keera-Session: <sandbox id> |
| Attaching | ssh, from its owner or an operator | nothing attaches |
| How work leaves | the developer pushes | a branch, pushed by the agent |
An agent sandbox's work is already pushed as a branch, so nothing in it is worth keeping. An engineer's sandbox holds unfinished work, so it is kept.
An expired engineer sandbox keeps its volume and loses its access. Its key
and repository credential are revoked. It still counts towards the quota and
keeps its name, because its volume still takes up space. On Kubernetes, only a
class with a disk has one; without it, the files are gone. keera sandbox resume
brings it back with a new key, a new repository credential and the class's
default lifetime, under the guardrails as they are now. The same ssh keys let
you in. To change its lifetime, extend it after resume; extend does not
work while it is expired. terminate frees the volume.
A sandbox that fails is over. Nothing resumes it. Its key and repository
credential are revoked at once. An agent's sandbox is also removed. An
engineer's keeps its volume, in case it holds work, until somebody terminates
it. Until then it is still live, like an expired one: it counts towards the
quota, keeps its name and shows in keera sandbox ls. A sandbox that has ended
shows only with --all.
keera sandbox list takes --class and --purpose (engineer or agent) to
narrow it, and keera sandbox show <name> prints one sandbox in full: its
state, image, repository, branch, expiry and running time.
Idle means no connection. With KEERA_SANDBOX_IDLE_SUSPEND set, an
engineer's sandbox is suspended once no connection has been open to it for that
long. An open keera sandbox ssh or editor session counts, even if nobody
types. The clock starts again when a sandbox is resumed.
Nothing attaches to an agent sandbox. A shell would not be less safe - the isolation is the same. The point is that only a branch comes out of it.
What creating one needs. A name of lowercase letters, digits and interior
hyphens, at most 40 characters, because it becomes a hostname in the cluster.
An engineer's sandbox needs an ssh public key: keera sandbox create sends the
ones in ~/.ssh, or the one in the file --ssh-key names, such as
--ssh-key ~/.ssh/work.pub. An agent's needs both --repo and --task.
--repo is cloned at its default branch, or at --branch, with only the
latest commit; git fetch --unshallow gets the rest. --ttl sets the
lifetime; without it, the sandbox gets the class's default. A --ttl above the
class's max_ttl or the quota's --max-sandbox-ttl is cut down to
it, not refused. Through the API, env adds variables for the sandbox's life,
until a resume after it expired drops them. It cannot name a KEERA_ variable:
those are the gateway's.
The SDK of agent-sandbox,
the controller the Kubernetes driver uses, is built for an agent outside that
drives a sandbox through Run() and Files().Write(). That sends the source
code out through the API on every read.
In Keera the agent runs inside (Run an agent in a sandbox): the image's coding agent (Pi in the base image) works on the checkout and pushes a branch. Where egress is limited, only that branch leaves. When the agent exits cleanly, the sandbox is terminated. When it fails, its machine is removed too, and the sandbox is marked failed with the reason.
The agent-sandbox controller has no isolation of its own; it uses a RuntimeClass.
So the tier is set per class. Each class names its tier, and the deployment
maps each of the three tiers to a runtime name once: a RuntimeClass on
Kubernetes, an OCI runtime on podman.
| Tier | Runtime | Boundary | Cost |
|---|---|---|---|
standard |
the cluster's own; on podman, the host's | the host kernel, as for any pod | none |
isolated |
gVisor (runsc) |
syscalls served in userspace | some syscall-heavy work; no io_uring, some FUSE, no nested containers |
vm |
Kata; on podman, krun |
a kernel of its own | 1–2s of boot, about 100 MB per sandbox, hardware virtualisation |
vm is the best tier for an agent sandbox, since it runs code a model wrote.
It is not the default, because of hardware. Kata and krun need hardware virtualisation on the node. When the nodes are VMs themselves - VMware, Nutanix, most Swiss enterprise setups - that means nested virtualisation, which a platform team may not enable. So gVisor is a full tier, not a fallback.
A class that asks for a tier the deployment has not mapped is refused at creation, with the name of the setting to fix. It never runs at a weaker tier.
Every tier runs as a non-root user, with no-new-privileges and all
capabilities dropped. On Kubernetes, no service account token is mounted and
seccomp is RuntimeDefault. Podman uses its own default seccomp profile. The
root filesystem stays writable, because people work in a sandbox.
The gateway's one listener has a fourth surface:
GET /sandbox/v1/{name-or-id}/tcp/{port}
Connection: Upgrade
Upgrade: keera-sandbox/1
The gateway authenticates the caller as it does for the control API, checks that
the sandbox is theirs, dials it, answers 101, and copies bytes until one end
stops. A connection with no bytes either way for two hours is closed, such as
one from a laptop put in a bag. The far end is a normal ssh server, so VS Code
Remote-SSH and JetBrains Gateway work unmodified.
sshd listens on 2222, not 22, because a process with every capability dropped cannot bind a privileged port. Nobody types the number: the gateway dials it, and a developer goes through the ProxyCommand. Which other ports can be reached depends on the driver. How to connect is in Sandboxes.
keera sandbox config writes a ProxyCommand that runs keera sandbox proxy.
It speaks the upgrade above and pipes it to its own stdin and stdout. The
command names the gateway it was made for with --url, so ssh always reaches
the sandbox through that gateway.
What this needs from a proxy in front. The upgrade must survive whatever
publishes the gateway. nginx-ingress and Traefik pass Connection: Upgrade by
default. If a proxy strips it, keera sandbox ssh gets a 426. The message asks
for the Upgrade header and names keera sandbox ssh as the client, so a 426
from the command itself points at the proxy.
| See it | Terminate, suspend, resume, extend | Open a shell | |
|---|---|---|---|
| Operator | yes | yes | yes |
| Organisation administrator | yes | yes | no |
| The owner | yes | yes | yes |
| Anybody else | no | no | no |
An agent's sandbox runs its task to the end: it cannot be suspended or resumed, and it is terminated when it expires.
An administrator can see and terminate every sandbox in their organisation, because the quota and the bill are theirs. They cannot get inside: a sandbox holds someone's source code and what they typed into a terminal. An administrator who needs access can create their own sandbox on the same repository.
A sandbox the caller may not see answers 404, not 403. And a name is unique per
person, not per organisation, so creating one never fails because a colleague
used the name. So nobody can use this to find out whether a colleague has a
sandbox called acquisition-model.
A name finds the caller's own sandbox first. An administrator who can see two
sandboxes of that name, from two people, gets a 409 and names one by its id
(sbx_…).
An organisation somebody created by signing up gets no sandbox until it has paid once, or an operator lifts its limit. See What a new organisation can use.
A class name is an API contract. People type it, commit it into repository config and get used to it. It is lowercase letters, digits and interior hyphens, at most 40 characters. So an administrator can change its image, isolation tier or memory without anyone else editing anything.
Each class belongs to one organisation, like a model. Only its people and
agents can ask for it, and two organisations can each have a standard.
Sandbox pods pull their image with the deployment's registry credentials. So
an administrator may only give a class an image of a class in the template
file (KEERA_SANDBOXES_FILE), one KEERA_SANDBOX_IMAGES allows, or the image the class
already has. Any other image needs an operator. An entry in
KEERA_SANDBOX_IMAGES that ends in / allows every image under it. Any other
entry allows only that image, with any tag or digest.
# KEERA_SANDBOXES_FILE
sandboxes:
- name: standard
description: The repo toolchain and an agent.
image: registry.internal/keera/sandbox-base:1
isolation: isolated # standard | isolated | vm
cpu: "4" # or 4000m
memory: 16Gi
disk: 50Gi # empty keeps nothing across a suspend (podman always keeps home)
default_ttl: 8h
max_ttl: 24h
warm: 2
purposes: [engineer] # empty is both
- name: agent
image: registry.internal/keera/sandbox-agent:1
isolation: vm
cpu: "4"
memory: 8Gi
default_ttl: 1h
max_ttl: 4h
purposes: [agent]The file is a template: it only seeds new organisations, which then own their copies.
keera sandbox classes lists the organisation's classes, and whether the
default project's quota lets you start each one. The default project is the
oldest one, where a sandbox lands without --project.
An administrator adds or changes classes with a file in the same format, with
keera sandbox apply; see
Sandboxes. It
checks the whole file first, then sets each class it names. Classes the file
does not name are left alone. A class is removed in the panel, or with
keera sandbox delete-class <name>. Sandboxes already running on it keep
working.
A field left out gets a default:
| Field | Default | Allowed |
|---|---|---|
isolation |
isolated |
standard, isolated, vm |
cpu |
2 cores | more than 0 |
memory |
4Gi | more than 0 |
disk |
none | none, or more than 0 |
default_ttl |
4h | 5m to 28 days |
max_ttl |
24h | 5m to 28 days, at least default_ttl |
warm |
0 | 0 to 32 |
The organisation controls these, not the operator. Its administrators
choose each class's isolation tier, size, lifetimes and warm pool. That
includes standard, which shares the host's kernel with other organisations'
sandboxes on the same nodes, and warm pools, which hold CPU and memory whether
anyone uses them or not. The operator only decides which stronger tiers exist
(KEERA_SANDBOX_RUNTIME_*) and whether warm pools are on at all
(KEERA_SANDBOX_WARM).
On Kubernetes, a class with no disk gets an 8Gi scratch directory
(emptyDir) that does not survive a suspend.
Deleting a class does not break running sandboxes. Each one keeps its own copy
of the image, the isolation tier and the resources it was given. The class's
lifetimes are gone with it, so it gets those of a class that sets none: 4h when
resumed or extended without --ttl, and at most 24h. The guardrail still
applies.
Sandbox quotas are fields on the guardrail row. They combine by the usual restrict-only rule: the minimum for a ceiling, the intersection for a list. Sandboxes shows how to set them.
--max-sandbox-memory is in MiB, so 32768 is 32 GiB.
--max-sandboxes counts every live sandbox: starting, running, suspended,
expired, and an engineer's that failed. The last two still hold a volume, if
their class has a disk.
A size ceiling refuses a request; it does not quietly downgrade it. A scope capped at four cores that asks for an eight-core class gets an error, not a smaller machine that cannot build its code.
Extending is safe to allow. The new lifetime counts from now, not from what is left, and the same two ceilings as at creation apply. The sandbox's key gets the new expiry too. A developer can extend as often as needed, and the sandbox still cannot live forever.
Quotas attach to the organisation and the project. The organisation's own quota
is set by its administrators, like its classes. Only allowed_repos on the
organisation is set by an operator. There is no per-key sandbox
quota: a sandbox's key is minted for that sandbox and dies with it. A key's
guardrail refuses the sandbox fields.
The panel's guardrails dialog has no sandbox fields. Its Sandboxes screen shows what is left of the quota and refuses a class the quota forbids. Saving the guardrails dialog keeps the sandbox fields unchanged: a guardrail is written whole, and the dialog only changes the fields it shows.
keera sandbox create <name> --project <project>, or the Project field in the
panel's dialog. The sandbox's key is scoped to that project, which decides:
- which budget the agent's inference is charged to,
- which rate limit it uses,
- whose sandbox quota the machine counts against,
- which system prompt every request it makes carries,
- and which models its agent may reach, which is what the picker inside the sandbox shows.
Without a project, the sandbox goes in the organisation's oldest project.
The project must belong to the same organisation. The foreign key only checks that the project exists, so the control plane checks the owner. Otherwise a sandbox could use another organisation's system prompt, budget, rate limit and model allow-list.
Only an administrator may choose a project. A member's sandbox goes in the organisation's oldest project, just as a member cannot choose the project of a key.
Sandbox time is recorded beside tokens, so a task's cost includes the machine it
ran on, such as "eleven minutes of a four-core machine". keera sandbox usage
groups it --by project, user or class, and takes --since, such as
30d.
Only an administrator can read it. The report counts sandboxes, how many of them are live now, how long they ran, and core-seconds, because no single number tells the whole story. A sandbox is counted whole in the window it was created in. Forty sandboxes that each lived ninety seconds is an agent fleet; one that lived a week is somebody who forgot. Core-seconds is what a chargeback uses.
Running time is summed across runs, not derived from timestamps, because a sandbox can be suspended and resumed many times. The clock stops as soon as a sandbox is suspended or terminated, not at the next sweep, and never runs past the sandbox's expiry. A sandbox that stops by itself before then is only noticed at the next sweep, and is charged until then.
If the gateway was down, the time it could not observe is still charged up to the expiry, because the sandbox was running.
Ended sandboxes are deleted after KEERA_USAGE_RETENTION, so --since reaches
back no further than that.
In. At creation the gateway mints a credential for the sandbox: one repository, short-lived, and never the developer's own. No ssh agent is forwarded.
Two forges are built in. Each needs a credential of the deployment's own, which stays on the gateway.
| GitHub | GitLab | |
|---|---|---|
| What a sandbox gets | an installation token of a GitHub App | a project access token |
| Scope | one repository, contents read and write | one project, Developer role, repository read and write |
| Lifetime | one hour, fixed by GitHub | until midnight UTC after the sandbox ends, and revoked when it ends |
| The deployment's credential | the App's id and private key | a token with the api scope, held by a Maintainer of the projects |
If neither is configured, a sandbox with a repository is refused with that reason, instead of failing inside the sandbox.
Any address works. git@host:group/app.git, ssh://… and https://… are
all accepted and cloned over HTTPS, because tokens only work there. A repository
on a different host than the forge's is refused.
Only the repositories the guardrail allows. The deployment's forge
credential can reach every repository it is installed on, including other
organisations'. So a sandbox gets a token only for a repository in
allowed_repos, and one whose organisation does not set it gets none
(Repositories).
An entry is a path on the forge: an owner or group for everything under it, or
one repository. '*' allows any repository, for one organisation. Case does
not matter. Only the organisation grants
repositories, and only an operator can set its list. A project's list can only
narrow it, and an administrator can set that. It is checked again on every
token refresh, so taking a repository off the list stops running sandboxes
getting a new token.
Tokens are refreshed. A GitHub token lasts an hour; an engineer's sandbox
lasts a working day. Git in the sandbox asks git-credential-keera, which keeps
the current token. When it has five minutes left, the helper gets a new one from
POST /sandbox/v1/git-credential with the sandbox's own key. That route takes
10 requests a minute per client address on each gateway replica, and sandboxes
that reach the gateway from the same address share it. When the forge gives no
token, it answers 409 no_git_credential. The gateway revokes the replaced
token where the forge allows it. The helper only answers for the repository's
own host, so a submodule on another host never sees the token. A resumed
sandbox keeps the token the helper last got, unless the gateway handed it a new
one, as it does when an expired sandbox is resumed.
When the sandbox ends, so does access. Its key is revoked, so it cannot get another token. A GitLab token is revoked at once. A GitHub token cannot be revoked without the token itself, which is not kept, so the last one works for up to an hour more.
When the owner is disabled, their sandboxes end or are suspended and their repository credentials are revoked; see sso.md.
What it cannot do. Neither token can change CI: GitHub gets no workflows
permission, and GitLab's Developer role cannot push to a protected branch. Keep
the default branch protected, and an agent can only propose a change.
Setting up GitHub: create a GitHub App with Repository permissions → Contents:
Read and write and nothing else, install it on the repositories sandboxes may
use, and download its private key. For GitHub Enterprise Server, set
KEERA_SANDBOX_GIT_URL to https://<host>/api/v3.
Setting up GitLab: create a token with the api scope for a user, group or
service account that is a Maintainer of those projects. Project access tokens
need GitLab Premium on GitLab.com; self-managed GitLab has them on every tier.
Out. Push a branch. The agent runner never writes to a default branch: its output is a proposal, and a person opens it.
Cold start takes 3–8 seconds on a node that already has the image, a minute or more on one that does not, plus a second or two for a microVM.
Warm pools fix this. They are opt-in twice - KEERA_SANDBOX_WARM for the
deployment, warm: per class - because every warm sandbox holds its class's
full CPU and memory all the time, used by nobody. The operator sets the first,
and each organisation's administrators set the second. An organisation that
signed itself up gets no pool until its first payment, like its sandboxes.
Only an engineer's sandbox comes from a pool. A pool member is started before anybody asks, as an engineer's sandbox that is ready once sshd answers. An agent runs no sshd, so an agent's sandbox always starts cold, and a class only agents may use cannot have a pool.
They need the upstream warm-pool extension (extensions.agents.x-k8s.io/v1beta1:
SandboxTemplate, SandboxWarmPool, SandboxClaim) installed next to the
controller. The pool's update strategy is OnReplenish, not Recreate, so
changing a class in the afternoon does not empty the pool while people use it.
The downside: for a few minutes after a change, some sandboxes get the previous
image. A sandbox records its class's image when it is created, so during those
minutes the recorded image can be the new one while the pod still runs the old.
Whether a sandbox came from a pool is stored on its row, not derived from configuration. So a deployment that turns warm pools off still knows which sandboxes came from one.
Pools do not outlive their class. The sweep deletes the pool and template of a
class the organisation removed or set back to warm: 0, and a gateway started
with warm pools off deletes every pool it finds. Both need list on templates
and pools.
The podman driver is not meant for production yet. It is there for local
development and tests. Use kubernetes for real workloads.
kubernetes |
podman |
|
|---|---|---|
| Where | a cluster, via agent-sandbox | one host |
| Isolation | whatever RuntimeClasses are mapped | whatever OCI runtimes the host has |
| Expiry enforced by | the controller | the gateway |
| Network isolation | a namespace NetworkPolicy, if the CNI enforces it | none |
| Warm pools | with the extension | no |
| Suspend/resume | yes | yes (stop/start) |
| Volumes | a PVC, or an emptyDir with no disk |
a named volume, always, unsized |
| Ports reachable | any from 1024 to 65535 | 2222 only |
| Process exits, 0 | failed: a finished pod cannot restart | suspended: it can start again |
| Whose it is | org and project labels, owner annotation | owner, org and project labels |
keera sandbox proxy <name> --port <port> reaches one of those ports. It pipes
the port to its own stdin and stdout, as it does for ssh.
The exit row is for an engineer's sandbox whose process ends on its own. An agent that finishes is terminated on both.
Expiry is the real difference. On Kubernetes it is spec.shutdownTime and the
cluster enforces it, so a gateway that crashes and never comes back leaves no
sandboxes running. An engineer's sandbox keeps its volume, as it does when it
expires. On podman there is no controller, so the gateway's sweep
enforces it, from the expiry on the sandbox's row. A gateway that is down over
a weekend comes back to containers that should have ended on Friday. The sweep
that runs at start-up ends those.
To run podman locally, the gateway must run as a host process, under
make dev; see
compose/README.md.
A sandbox's API key is in its pod spec. The key is minted for that sandbox, expires with it and is revoked when it goes. A Secret would be a second object with the same lifetime and the same value, and one more thing to leak when a sandbox is terminated uncleanly.
The cost: anybody who may read pods in the sandbox namespace may read every live sandbox's key. So sandboxes get their own namespace, not the gateway's. The Helm chart (not in this repository) creates it, gives the gateway's service account a Role scoped to it, and puts a default-deny NetworkPolicy over every pod in it.
The Role is small: Sandboxes and, with warm pools, their templates, pools and claims; read-only on the pods, Services, PVCs and events the controller makes. Nothing cluster-scoped, no Secrets, no other namespace.
pods/portforward is not granted. The driver reaches a sandbox by dialling
its pod IP from inside the cluster, which needs no permission. Port-forward
would let the holder reach any pod in the namespace on any port. So the gateway
has to run in the cluster where it creates sandboxes.
Egress. The NetworkPolicy comes from the Helm chart's
networkPolicy.enabled. Its egress rule allows DNS, the gateway, and whatever
the deployment adds in sandboxes.egress.extra. The internet is not on that
list, on purpose. Cloning and pushing need the forge, so add its host there.
A deployment that needs package downloads points its sandboxes at an internal
proxy and adds it there too. That is also the only way this works on an
air-gapped site.
The rule covers the whole namespace and needs a CNI that enforces it. On a network plugin without NetworkPolicy support - flannel without a policy plugin, for example - the policy does nothing, and nothing warns you. Check it on the cluster.
A podman sandbox has unrestricted outbound access. It joins podman's default
bridge and can reach the internet, the host and the LAN. This is tested:
curl https://github.com from inside one returns 200. Use the single-host
driver for development, or on a host the deployment already trusts.
| Variable | Default | What it does |
|---|---|---|
KEERA_SANDBOX_DRIVER |
- | kubernetes, podman, or unset for no sandboxes, and no Sandboxes screen in the panel |
KEERA_SANDBOXES_FILE |
- | the classes each new organisation starts with |
KEERA_SANDBOX_NAMESPACE |
the gateway's | where sandboxes run on Kubernetes. Should not be the gateway's, so unset logs a warning |
KEERA_SANDBOX_RUNTIME_STANDARD |
- | the runtime for the standard tier, if not the default: a RuntimeClass, or on podman crun or runc |
KEERA_SANDBOX_RUNTIME_ISOLATED |
- | the gVisor RuntimeClass, or runsc on podman. Unset makes that tier unavailable |
KEERA_SANDBOX_RUNTIME_VM |
- | the Kata RuntimeClass, or krun on podman. Unset makes that tier unavailable |
KEERA_SANDBOX_PUBLIC_URL |
KEERA_PUBLIC_URL |
the gateway as a sandbox reaches it - the in-cluster Service, not the ingress |
KEERA_SANDBOX_IDLE_SUSPEND |
off | how long an engineer's sandbox runs with no connection open before it is suspended. Refused below 5m |
KEERA_SANDBOX_STORAGE_CLASS |
the cluster's | on Kubernetes, what a home volume is provisioned from |
KEERA_SANDBOX_SERVICE_ACCOUNT |
the namespace's | on Kubernetes, what a sandbox pod runs as; its token is never mounted |
KEERA_SANDBOX_IMAGE_PULL_SECRETS |
- | on Kubernetes, comma-separated, for a sandbox image in a private registry |
KEERA_SANDBOX_IMAGES |
- | comma-separated image prefixes an administrator may give a class, such as registry.internal/keera/ |
KEERA_SANDBOX_WARM |
off | on Kubernetes, warm pools, which need the upstream extension |
KEERA_SANDBOX_PODMAN_BINARY |
podman |
the single-host driver's command |
KEERA_SANDBOX_PODMAN_NETWORK |
podman's default | the network a single-host sandbox joins |
KEERA_SANDBOX_GIT_FORGE |
- | github, gitlab, or unset for sandboxes without a repository |
KEERA_SANDBOX_GIT_URL |
GitHub.com or GitLab.com | GitHub's API (https://<host>/api/v3 for Enterprise Server), or the GitLab instance |
KEERA_SANDBOX_GIT_APP_ID |
- | the GitHub App's id |
KEERA_SANDBOX_GIT_APP_KEY_FILE |
- | a file holding the GitHub App's private key |
KEERA_SANDBOX_GIT_TOKEN_FILE |
- | a file holding the GitLab token |
The gateway does not start with an unknown driver or forge, with
KEERA_SANDBOX_GIT_FORGE=github but not both the app id and its key file, or
with gitlab but no token file.
With no driver, the panel hides sandboxes and the other sandbox settings are
ignored, so a stray one does not stop the start. KEERA_SANDBOXES_FILE and
KEERA_SANDBOX_IMAGES are read either way, under compose too: the classes are
there once a driver is set, and an administrator can save a class with an
allowed image before then. A missing or invalid classes file stops the start;
see Template files.
KEERA_SANDBOX_PUBLIC_URL is the one to get right. A sandbox pointed at the
public name leaves the cluster and comes back through the load balancer to reach
a pod nearby. On the podman driver, 127.0.0.1 inside a sandbox is the sandbox
itself. The host is host.containers.internal, and the gateway must listen on
more than loopback to answer there. The default, KEERA_ADDR=:8080, already
does.
sandbox/Containerfile builds a base image. A deployment builds its own on top,
with the languages, language servers, internal certificates and agent its
engineers need, and names that image in its classes. Build it with
make sandbox-image, or podman build -t <tag> sandbox: the build context is
the sandbox/ directory, not the repository root.
The base provides what the gateway depends on: a fixed uid 1000 (the home volume
uses it as fsGroup), sshd, the coding agent, and the entrypoint.
It is Alpine, and the coding agent is Pi, pinned. Alpine because its Node is
new enough for Pi (see sandbox/Containerfile). Pinned because an image is
built once and run for months, and two sandboxes of the same class must run the
same software.
The version is in sandbox/pi/package.json, and package-lock.json beside it
pins every package under Pi. To update, change the version and run
npm install --package-lock-only in that directory.
The gateway configures Pi, not the image. The manager renders
~/.pi/agent/models.json and the entrypoint writes it.
It lists every model and router the sandbox's own key may reach - the organisation's allow-list narrowed by the project's - not just one model the deployment picked. Listing models the key is refused for would end prompts in refusals. Listing only one would hide the rest. Only chat models are listed, and the routers come after them.
The key is not in that file. The file names ${KEERA_API_KEY}, which Pi
expands from the environment.
The home volume does hold the key. The entrypoint writes the environment,
key included, to ~/.ssh/environment, because that is how an ssh session gets
it. The Git helper caches the repository token in ~/.config/keera. So whoever
can read a sandbox's volume can read both, until they are revoked: when the
sandbox expires or ends, or its owner is disabled
(GitHub tokens last a little longer).
The file is rendered from the same template keera connect pi prints for a
laptop, so Pi names itself and its models the same way in both. An agent's
sandbox adds its session header.
A developer who edits their own models.json keeps it: the entrypoint rewrites
the file only while it still matches what the gateway last wrote.
No vendor credential is exported into a sandbox, so the picker stays honest.
Pi has built-in Anthropic and OpenAI providers wired to those vendors'
endpoints. They turn on when ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN or
OPENAI_API_KEY is set. Setting any of them fills /model with dozens of GPT
and Claude models the key cannot use, and picking one sends the gateway's key
and the prompt to the vendor. Pi's documentation says a provider with no auth
"loads but stays unavailable in /model", so with them unset the picker shows
only the organisation's allow-list.
The base URLs stay set, because they are not credentials and Pi ignores them. A
deployment whose image adds Claude Code or an OpenAI SDK sets that client's
variable from KEERA_API_KEY in its own entrypoint - one line. Doing so turns
that vendor's provider back on in the picker.
Pi's default model and provider are pinned too, in
~/.pi/agent/settings.json. The model is the first chat model the key may
reach, or its first router when it reaches no model, so the project's
allow-list decides it.
Pinning defaultProvider: keera is a security control. Pi picks its built-in
providers over a configured one when both are available. So pi with no
--model, on an image that exports a vendor variable, would send the gateway's
key to api.anthropic.com. Naming the provider stops that. Any tool in a
sandbox that reads a vendor's variable and ignores its base URL does the same,
and the egress rule is the only general defence.
The environment reaches an ssh session through ~/.ssh/environment and
PermitUserEnvironment, not a shell startup file. An ssh session inherits
nothing from the container. Alpine's bash does not read ~/.bashrc for a
non-interactive ssh host cmd, which scp and every editor's remote server use.
The file holds the container's whole environment, with the image's PATH and
any extra variables the caller set. Left out are TERM, values with a line
break, and what only the start uses: the task, the ssh keys, Pi's files and the
first repository token.
The entrypoint prepares the home directory idempotently, has every shell startup file load that same environment, checks out the repository, and then starts sshd or runs the agent. sshd starts last, so a sandbox is ready only once sshd answers, which also means setup finished. Both drivers check this: Kubernetes with a readiness probe on the port, podman by connecting to the published port and waiting for sshd's greeting, so the image needs no shell for it. An agent sandbox runs no sshd and has no such check: it is ready once it has started.
The host key is generated at build time, so every sandbox from an image shares
it. It proves nothing about which sandbox you reach, and it does not need to:
the gateway has already authenticated you over TLS and dials the sandbox
itself. So keera sandbox ssh and keera sandbox config turn host-key
checking off, and nobody learns to click through an unknown-host prompt.
The gateway tells the entrypoint what to do through these variables. An image that replaces the entrypoint has to handle them itself.
| Variable | What it holds |
|---|---|
KEERA_API_KEY |
The sandbox's own key. |
KEERA_BASE_URL |
The gateway's inference address. Also as OPENAI_BASE_URL (with /v1) and ANTHROPIC_BASE_URL. |
KEERA_MODEL |
The first chat model the key may reach, else its first router. Also as ANTHROPIC_MODEL. |
KEERA_SANDBOX_ID, _NAME, _CLASS, _PURPOSE |
Which sandbox this is. |
KEERA_SANDBOX_EXPIRES |
When it ends, in RFC 3339, as set at creation or when resumed after expiring. extend does not. |
KEERA_AUTHORIZED_KEYS |
The ssh public keys that may log in, one per line. |
KEERA_SESSION |
An agent sandbox's session id. Also in ANTHROPIC_CUSTOM_HEADERS. |
KEERA_TASK |
The agent's task, when one was given. |
KEERA_REPO, KEERA_BRANCH |
The repository and branch to check out. |
KEERA_GIT_USERNAME, KEERA_GIT_TOKEN |
The first repository credential. |
KEERA_GIT_TOKEN_EXPIRES |
When that token runs out, in Unix seconds. |
KEERA_GIT_CREDENTIAL_URL |
Where the Git helper asks for a fresh token. |
KEERA_GIT_AUTHOR_NAME, KEERA_GIT_AUTHOR_EMAIL |
The sandbox's owner, so commits are theirs. |
KEERA_PI_CONFIG, KEERA_PI_SETTINGS |
Pi's models.json and settings.json, which the entrypoint writes. |
No sandbox image is published. It would be a second family of images to build, scan and ship into air-gapped sites.
Nothing has run against a real cluster. The Kubernetes driver is tested
against a stand-in API server that answers the shapes the CRD documents. Most
likely to need a second look: the warm-pool claim's condition names, and whether
envVarsInjectionPolicy: Overrides behaves as documented.
agent-sandbox is v1beta1 and still changing. Two of its own notes matter
here: the Suspended condition is not cleared on resume, so trust Ready; and
volumeClaimTemplates cannot change after creation, so a class that grows its
disk cannot apply that to running sandboxes.
Keep GPU nodes and sandbox nodes in separate pools, so a developer filling a
node with build jobs does not take Keera Engine's GPU. The chart does not
enforce this; set nodeSelector on both.