Skip to content
Brad Johnson, home
All words · 8 min read

A Coding Agent Whose Sandbox Never Holds a Key

Tags:#ai#agents

I wanted a coding agent I could start from my desk on whichever machine has the code, that keeps working when I close the app, and that runs dependency installs and other untrusted code somewhere other than the machine with my SSH keys. aludra is the host that does that. It runs on each Mac or Linux machine whose projects I want to reach, and I talk to it from loial, a desktop app, from the aludra command, or over its HTTP API. The code is on GitHub.

The model doesn’t run the tools

A session is a Pi Durable conversation in a SQLite file owned by one long-lived process. Pi Durable checkpoints every step, so a session keeps going with every client closed, and a host that restarts calls harness.resume() and carries on with whatever the last process left unfinished. I wrote about that model in the gaidin post.

Model turns come from the Claude Code that’s installed and logged in on the machine. aludra hands it each turn with the transcript and a list of tools it can’t run. Claude Code answers with the model’s text and its tool calls, and Pi Durable runs the calls.

That split is what lets a session’s tools live somewhere else. Pi Durable asks aludra for an execution environment each time a tool runs, and aludra answers per session:

env: async (target, ctx) => {
	const { cwd } = target;
	const sandbox = await sandboxOf(target, ctx);
	// ...
	if (sandbox !== undefined) return sandboxes.env(sandbox.sandbox, cwd ?? sandbox.dir);
	return new NodeExecutionEnv({ cwd: cwd ?? homedir() });
},

A session on the machine gets a NodeExecutionEnv in its project’s directory. A sandbox session gets a RemoteExecutionEnv from Earendil’s pi-env, which deploys a small Rust daemon over SSH, checks its SHA-256 before each start, and gives read, write, edit and bash the same behavior on the far side. A sandbox passes all 29 cases of Pi Durable’s environment conformance suite through it in under five seconds. The model, the transcript and my approvals stay on the host.

A sandbox per session

The sandboxes are stedding VMs, Firecracker microVMs on a box in my homelab that can reach the internet and nothing else. They have their own post. Booting a VM and installing a project’s dependencies for every session would be slow, so aludra keeps a template per project, named for the project and its commit.

To make one, aludra starts a sandbox from the base image, sends it a git bundle of the local checkout’s HEAD and clones that with no remote. Then it runs the project’s .agents/setup if there is one, or installs from whichever lockfile it finds (pnpm install --frozen-lockfile, npm ci, cargo fetch or a Python venv), shuts the sandbox down and saves its disk. For Raken, the template took 13 seconds and the next session’s sandbox was ready in 1.

Templates are always made at the large size, 8 CPUs and 16 GB, whatever size the session asked for. A saved template keeps only its disk, so the extra CPUs and memory cost nothing once the install is done.

Every commit needs a new template, and a project I’m shipping from gets new commits all day. A cold template for loial, a Tauri app, took 1 min 40 s, most of it cargo check. So a new template starts from the project’s newest one under a day old, fetches the new commit over the old checkout, and installs again with node_modules and target still in place. This is the start of that script, as aludra’s template string writes it:

cd ${dir}
git fetch -q ~/project.bundle HEAD && rm ~/project.bundle
since=$(cat ${SINCE} 2>/dev/null || echo 0)
if (( $(date +%s) - since >= ${TEMPLATE_MAX_AGE_S} )) || ! git diff --quiet HEAD FETCH_HEAD -- .agents/setup; then echo ${STEP_MARK}fresh; exit 0; fi

The chain goes back to the base image once it’s a day old, when the new commit changes .agents/setup, or when the incremental attempt fails, so a setup that can’t run twice costs a retry instead of a broken template. Once a template is saved, aludra tells stedding to drop the project’s older ones.

A session doesn’t wait for any of that. Creating a sandbox session answers right away with the session marked preparing, and the sandbox gets made in the background, stepping through waiting, cloning, installing, saving and starting. Messages that arrive in the meantime, the first one included, are held in the database in the same commit that checks the sandbox isn’t ready, then sent in order once it is. In one live run the create call answered in under a second, the sandbox was ready in 15 seconds with a new template, and the held message had run and committed by 27 seconds. A host killed with kill -9 partway through picks it all up again on restart.

Paused between turns

A sandbox’s connection closes after 60 seconds with nothing in flight, and stedding pauses a sandbox nobody is connected to, memory and all. The next tool call opens a new connection. Its SSH target has a ProxyCommand that goes through stedding’s connect, which resumes the sandbox before relaying to it, so waking a sandbox is a side effect of using it. A process started with setsid is still running afterward. In the live check, the first turn started a background process, the sandbox was paused, and the second turn, 7 seconds long, found the process running with the same pid. A project can include an .agents/resume script for anything that needs restarting after a wake, and aludra runs it before the call that woke the sandbox.

Getting the work back

When a run ends, the sandbox bundles its HEAD, the bundle comes back over the pi-env connection, and git fetch writes it to a sandbox/N branch in my local checkout. The branch is created or fast-forwarded and never forced, and the branch I have checked out and my working tree aren’t touched. The numbers come from a counter in aludra’s database that skips any sandbox/N already in the checkout, so a new session never lands on an old session’s branch.

With afterRun set to merge, aludra also fast-forwards the checked-out branch when three things hold: the checkout is on a branch, sandbox/N contains it, and no uncommitted change touches a file the new commits change. It never makes a merge commit. In a live run the commit was on main 8 seconds after the answer.

Shipping from the host

A push needs a credential, and I didn’t want one in the sandbox. A sandbox runs installs from lockfiles and reads web pages, and GitHub can’t scope a token to “branches only” without rules on each repository. The host is always on and already has my git identity, so the host pushes. The idea comes from Amp’s Ship.

A sequence diagram with three columns: the aludra host, which has the git identity and keys; the sandbox, with no remote and no keys; and GitHub, with the upstream branch. The host fetches the upstream branch and bundles it into the sandbox as refs/aludra/base. In the sandbox the agent, following the ship prompt, commits the work and rebases, resolves conflicts, runs the checks and says SHIP-READY. The bundle comes back into sandbox/N on the host, which checks that nothing is left uncommitted and that sandbox/N contains the base, and otherwise pushes nothing. Then the host runs git push without force, to main or to a branch with a pull request.

The session gets a prompt that spells out the steps: commit anything that belongs to the work, rebase onto refs/aludra/base, resolve conflicts, run the tests and checks, and end with a line saying SHIP-READY or SHIP-BLOCKED: and why. It’s told not to push or change remotes. Each command it runs during a ship stops after 5 minutes.

Then the host checks the result itself before anything leaves the machine:

const synced = await host.sandboxes.sync(id, undefined, { force: true });
if (synced.uncommitted > 0) throw new Stop("check_failed", /* ... */);
const onBase = await git("merge-base", "--is-ancestor", sha, synced.head).then(() => true, () => false);
if (!onBase) throw new Stop("check_failed", /* ... */);
// ...
// Without force: if the base moved since the fetch, this fails and the user ships again.
await git("push", "--quiet", base.remote, `${synced.head}:refs/heads/${base.branch}`);

That sync is forced because the rebase rewrote the branch. The push is never forced. If the upstream moved during the ship, GitHub rejects the push and the next ship rebases onto the new commit.

A ship goes to main by default, or to a sandbox-N-<sha> branch with a pull request from gh pr create. Pressing Ship is the approval, so the push doesn’t wait in the approvals queue the way an agent’s own git push would, and a ship cut off by a restart is finished when the host comes back. Work that’s already upstream lands as “Already on main” with nothing pushed. On a scratch copy of Raken with an extra commit pushed to its origin first, a ship committed the work, rebased onto that commit, ran pnpm test and pnpm check, and pushed one commit to main in 20 seconds. Other live ships landed in 21 and 30.

What the host checks itself

Each reply ends with a fenced plan block, and a reply that asks me something adds a result block whose question has options. They’re blocks in the answer instead of a tool, so they work with any model provider, and nothing is stored for them. aludra reads them from the transcript when a client asks.

loial showing a demo project’s session in sandbox/0, at step 2 of 3 with a bash call running, the plan checklist with Read the project done and Look around the directory in progress, and a box below for steering or queueing a follow-up.

A stand-in aludra host with a scripted model and a fake sandbox.

What the agent says about its tests is a different matter. A project declares its checks in .agents/project.json, next to .agents/setup, so they travel with the repository into the template. When a run ends with an answer, aludra runs them in the sandbox itself and records the results, and in the live check that took about a second. The size of the change comes from diffing sandbox/N against the commit the sandbox started from, in the local checkout.

In a sandbox, approvals are off by default. On the machine, git push and SSH to the hosts I list wait for me, but a sandbox has no remotes, no keys and no route to those hosts. The protected-path gate, which blocks reading .env files, keys and agent logins, stays on in sandboxes, since anything a tool reads ends up in the transcript on the host.

It’s TypeScript on Node with Pi Durable 1.3.0, pinned along with everything else to exact versions at least three days old. In the Wheel of Time, Aludra is the Illuminator who invents things.