Skip to content

Commit ecc7c4b

Browse files
Merge pull request #8 from devweekends/feat/runtime-sandbox
Feat/runtime sandbox
2 parents 5445e87 + 30b2ec9 commit ecc7c4b

12 files changed

Lines changed: 906 additions & 26 deletions

File tree

‎README.md‎

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ Week 1 (Oct 19 to 23, 2026), plus the first part of week 2.
6969
| Piece | File | State |
7070
| --- | --- | --- |
7171
| Fake runner, the stand-in the daemon builds against | [runner/src/fake-runner.ts](runner/src/fake-runner.ts) | Done |
72-
| Session records and legal state moves | [runner/src/session-store.ts](runner/src/session-store.ts) | Done |
72+
| Session records and legal state moves | [runner/src/session-store.ts](runner/src/session-store.ts) | Done. The real runner saves them under `~/.codehands/sessions`, so they outlive a restart |
73+
| `recover()`: clean up after a restart | [runner/src/runner.ts](runner/src/runner.ts) | Done. Not in the contract's `Runner`: the daemon calls it once at startup, on the type `createRunner` returns |
7374
| `seq` 1, 2, 3 … per session | [runner/src/events.ts](runner/src/events.ts) | Done |
7475
| Claude Code arguments and stream-json parser | [runner/src/agents/claude-code.ts](runner/src/agents/claude-code.ts) | Done, tested against a recorded run |
7576
| Worktrees: branch names, add, remove, prune | [runner/src/git/worktree.ts](runner/src/git/worktree.ts) | Done |
@@ -80,7 +81,8 @@ Week 1 (Oct 19 to 23, 2026), plus the first part of week 2.
8081
| Codex: `codex exec --json` to events | [runner/src/agents/codex.ts](runner/src/agents/codex.ts) | To do. `start()` with `agent: "codex"` fails cleanly for now |
8182
| `approval.requested` after `task.finished` | [runner/src/runner.ts](runner/src/runner.ts) | Done (contracts v0.2.0). Real and fake runners, never after a stop or a failure |
8283
| Repo memory: notes read before a run, a note added after | [runner/src/memory/](runner/src/memory/) | Done. `MemoryStore` with an in-memory stub and a file store. Stopped and failed runs add nothing, and a memory error never fails a run |
83-
| Repo profile: language, package manager, test command, Node version | [runner/src/memory/profile.ts](runner/src/memory/profile.ts) | Done. Read from files in the worktree, no model call. The sandbox reads it next |
84+
| Repo profile: language, package manager, test command, Node version | [runner/src/memory/profile.ts](runner/src/memory/profile.ts) | Done. Read from files in the worktree, no model call. The sandbox uses it to pick the image and decide whether to install |
85+
| Sandbox: run a repo's tests in a locked Docker box | [runner/src/runtime/](runner/src/runtime/) | Done. `runInSandbox()`: no network, 1 GB with no swap, 1 CPU, 256 processes, read-only repo and system, non-root, your timezone, 60 s. npm only. Nothing calls it yet: the engine's test step will |
8486

8587
Next, from the [design](https://github.com/devweekends/codehands-docs#who-owns-what): a model per phase, naming provider errors as one `provider.status` event (`rate_limited`, `auth_expired`, `usage_limit`, `subscription_inactive`, `down`), memory and user preferences, and two models on one task.
8688

@@ -113,17 +115,25 @@ const { branch, commit } = await runner.approve(sessionId, "Fix the date bug");
113115

114116
| Export | What |
115117
| --- | --- |
116-
| `createRunner(options?)` | The real runner. `worktreeRoot` overrides `~/.codehands/worktrees`. `memory` swaps where repo memory lives (default: files under `~/.codehands/memory`). `agentCommand` swaps the program it runs, which is how the tests replay a recording instead of calling Claude |
118+
| `createRunner(options?)` | The real runner. `worktreeRoot` overrides `~/.codehands/worktrees`. `sessionRoot` overrides `~/.codehands/sessions`. `memory` swaps where repo memory lives (default: files under `~/.codehands/memory`). `agentCommand` swaps the program it runs, which is how the tests replay a recording instead of calling Claude |
117119
| `createFakeRunner({ delayMs? })` | Emits 12 scripted events, 300 ms apart, then `approval.requested`, and supports every method including stop and resume. Costs nothing, needs no repo. Build against it, and keep it as the demo backup |
118120
| `EventSequence` | Hands out events with the next `seq` |
119-
| `SessionStore`, `SessionRecord` | In-memory session records with the legal-move check from the contracts |
121+
| `SessionStore`, `SessionRecord` | Session records with the legal-move check from the contracts. Saved as files when given a root, otherwise in memory (the fake runner) |
122+
| `RecoverableRunner`, `RecoveryReport` | What `createRunner` returns: the contract's `Runner` plus `recover()`, and what `recover()` reports |
120123
| `branchName(prompt, sessionId)`, `slugify(text)` | `codehands/fix-the-failing-test-3f2a9c1e` |
121124
| `MemoryStore`, `createMemoryStub()`, `createFileMemory(root?)` | Repo memory: `notes`, `addNote`, `profile`, `saveProfile`. The stub keeps it in a `Map` for tests |
122125
| `formatNote`, `runNote`, `trimNotes`, `notesForPrompt` | How a note is written, built from a run, trimmed to about 200 lines, and capped at 6,000 characters for the prompt |
123126
| `detectRepoProfile(dir)`, `RepoProfile` | What a repo is, read from its files. Never throws: an unknown repo gives `language: "unknown"` |
124127
| `repoKey(repoPath)` | The repo's id, which names its worktree and memory folders |
128+
| `runInSandbox(options)`, `SandboxOptions`, `SandboxResult`, `SandboxError` | Runs the repo's test command in the locked Docker box and returns a result. Never throws: failing tests are `passed: false`, and a box that couldn't run them sets `error` (`sandbox.unavailable`, `sandbox.unsupported`, `sandbox.failed`, `install.failed`) |
129+
| `dockerArgs()`, `installArgs()`, `DEFAULT_LIMITS`, `BoxLimits` | The exact `docker run` flags for the test box and the install box, as plain data |
130+
| `dockerAvailable(docker?)`, `hostTimeZone()` | Whether the Docker engine answers within 5 s, and your IANA timezone (`Asia/Karachi`, not Windows' own name) |
125131

126-
The runner keeps session records in memory only. The daemon owns the real database. A runner that restarts forgets its sessions; stop, resume and crash recovery are where that changes.
132+
When a repo needs its dependencies, a separate install box runs first, with the network on but the repo's install scripts off, then the test box runs with no network. `node_modules` must be git-ignored, or the sandbox refuses: everything not ignored lands in the commit you approve.
133+
134+
The daemon owns the real database. The runner keeps only what it needs to finish a session, in `~/.codehands/sessions/<sessionId>.json`, so `approve`, `reject`, `resume` and `diff` still work after a restart. The agent itself does not survive one.
135+
136+
When the daemon starts it should call `recover()` once, before anything else. Sessions still marked `queued` or `running` become `failed`, and their ids come back in `report.failed`. Failed sessions lose their worktree, along with anything uncommitted in it, because `failed` is final and nothing else would remove it. Their branch goes too, unless the agent committed to it. `awaiting_approval` and `stopped` sessions are left alone. A worktree that can't be removed comes back in `report.cleanupErrors` and is tried again next time.
127137

128138
### How Claude Code's output becomes events
129139

@@ -157,6 +167,9 @@ Some details that matter:
157167
| `~/.codehands/worktrees/<repoKey>/<first 8 of sessionId>` | The task's worktree. Outside your repo so worktrees never nest, and short because of Windows path limits |
158168
| `~/.codehands/memory/<repoKey>/notes.md` | Per-repo notes, newest last, trimmed to about 200 lines by dropping the oldest whole notes |
159169
| `~/.codehands/memory/<repoKey>/profile.json` | The repo profile from the latest run |
170+
| `~/.codehands/sessions/<sessionId>.json` | What the runner needs to finish a session after a restart |
171+
| `~/.codehands/runs/<sessionId>/test-<n>.json` | Each sandbox test run: passed, exit code, timed out, out of memory, the last 64 KB of output |
172+
| Docker containers `ch-<first 8 of sessionId>-<n>` | One per sandbox run, removed right after. `-install` on the end for the install box |
160173
| `codehands/<slug>-<first 8 of sessionId>` | The task's branch, in your repo |
161174

162175
`repoKey` is the first 12 characters of the SHA-1 of the repo's `origin` URL, or of its absolute path when there is no origin. Two clones of the same repo share a key.
@@ -165,7 +178,7 @@ All of it stays on this machine. Memory reaches Claude only as `--append-system-
165178

166179
## Safety rules
167180

168-
The agent runs on your machine with no sandbox until the Docker box lands in week 2. Until then:
181+
Tests run in the locked Docker box when the engine calls `runInSandbox()`. Claude Code itself still runs on your machine, not in a box, so:
169182

170183
- **Run it only on our own repos.**
171184
- Claude runs with `--permission-mode acceptEdits` and a short allow list: `Read, Edit, Write, Glob, Grep`, and only `npm test` in Bash or PowerShell. Anything else is blocked and shows up as a `message` with `denied: true`.
@@ -177,7 +190,7 @@ The agent runs on your machine with no sandbox until the Docker box lands in wee
177190

178191
## Set up
179192

180-
You need Node 22, pnpm 10, Git, and for real runs the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI, logged in.
193+
You need Node 22, pnpm 10, Git, and for real runs the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI, logged in. For the sandbox, Docker Desktop must be running. Without it, the sandbox tests are skipped, not failed.
181194

182195
The runner depends on the contracts by folder in week 1, so clone both side by side:
183196

@@ -202,7 +215,8 @@ Run these in `runner/`.
202215

203216
| Command | What | Costs money |
204217
| --- | --- | --- |
205-
| `pnpm test` | Every test. The parser replays a recorded run, the runner tests make real git repos in a temp folder and use a stand-in for `claude` | No |
218+
| `pnpm test` | Every test. The parser replays a recorded run, the runner tests make real git repos in a temp folder and use a stand-in for `claude`, and the sandbox tests start real containers when Docker is running | No |
219+
| `pnpm sandbox [repoPath] [--tz <zone>]` | Runs a repo's tests in the locked box once and prints the result. Defaults to the fixture. `--tz UTC` shows why the box needs your timezone | No |
206220
| `pnpm fake` | Prints the fake runner's events, one JSON object per line. This is exactly what the daemon receives | No |
207221
| `pnpm try [repoPath] [prompt] [--approve \| --reject]` | One real run through the runner, one line per event, then the diff stat, then approve, reject or keep | Yes, capped at $0.50 |
208222
| `pnpm build` | Compiles to `dist/` | No |
@@ -222,11 +236,14 @@ The scripts in `runner/scripts/`:
222236
| File | Covers |
223237
| --- | --- |
224238
| [claude-parser.test.ts](runner/test/claude-parser.test.ts) | The whole recorded run replays in order; each mapping above; blocked commands; failed tests hidden behind exit code 0; error results; broken lines; `raw` on every event |
225-
| [runner.test.ts](runner/test/runner.test.ts) | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves `main` alone; reject deletes the branch; nothing to approve; stop kills the agent and its children, keeps the worktree and refuses a session that is not running; resume continues the numbering, passes the prompt, can be stopped again and refuses a session that is not stopped; `approval.requested` right after `task.finished`; memory: the second run gets the first run's note, the profile is saved, and failed or stopped runs add no note |
239+
| [runner.test.ts](runner/test/runner.test.ts) | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves `main` alone; reject deletes the branch; nothing to approve; stop kills the agent and its children, keeps the worktree and refuses a session that is not running; resume continues the numbering, passes the prompt, can be stopped again and refuses a session that is not stopped; `approval.requested` right after `task.finished`; memory: the second run gets the first run's note, the profile is saved, and failed or stopped runs add no note; after a restart: approve, reject, diff and resume still work, and `recover()` fails interrupted sessions, removes their worktrees, keeps branches with commits, leaves live sessions alone, and reports what it can't clean or read |
240+
| [session-store.test.ts](runner/test/session-store.test.ts) | A new store finds what the old one saved; the pid is never saved; moves are still checked after loading; ids that aren't plain names never reach the disk; no root means memory only |
226241
| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing; no `approval.requested` after a stop |
227242
| [memory.test.ts](runner/test/memory.test.ts) | The stub keeps notes in order and repos apart; trimming drops whole notes and keeps the newest; the prompt cap keeps the newest end; a note's summary stays on one line; the file store reads back from disk and treats missing files as empty |
228243
| [profile.test.ts](runner/test/profile.test.ts) | npm, pnpm, npm's placeholder test script, `.nvmrc`, `engines.node`, needs install, broken `package.json`, Python, empty folder |
229244
| [worktree.test.ts](runner/test/worktree.test.ts) | Branch names |
245+
| [docker-args.test.ts](runner/test/docker-args.test.ts) | Every security flag of the test box and the install box, with no Docker needed, so a lost flag fails in milliseconds |
246+
| [sandbox.test.ts](runner/test/sandbox.test.ts) | Real containers, skipped when Docker is off: pass and fail; a test that never ends is killed and nothing is left behind, even when the kill misses; out of memory; no network; read-only repo and system; the 64 KB output cap; timezone; result files numbered across restarts; no false pass from a leftover container; `sandbox.failed` when the box can't start; install, with no install scripts and no lockfile added; a failed install; `node_modules` not git-ignored; Docker unavailable |
230247

231248
To record a new fixture, run `scripts/spike.sh` (or a Codex equivalent), read every line of the output, then write tests that replay it. Never call a real agent from a test.
232249

@@ -240,10 +257,6 @@ To record a new fixture, run `scripts/spike.sh` (or a Codex equivalent), read ev
240257
| Cannot find module `@codehands/contracts` | Build the contracts repo first, and check it is cloned next to `codehands-agent` |
241258
| Child processes keep running after a stop on Windows | Killing the parent does not kill its children (`claude` → `npm test` → `node`). That is what `stopProcessTree` is for |
242259
| "nothing to approve: the agent made no changes" | The agent changed no files. Reject the session instead |
243-
244-
## Contributing
245-
246-
- One branch per Linear issue, for example `tahira/cod-12-runner-start`. Put the issue id in the PR title.
247-
- `main` is always green. CI runs build and test on every PR.
248-
- A change to `StartOptions`, `RunnerEvent` or `Runner` is a contract change. It goes to [codehands-contracts](https://github.com/devweekends/codehands-contracts#changing-a-contract) first, and `createFakeRunner()` is updated the same week.
249-
- The week by week plan is in [roadmap/tahira.md](https://github.com/devweekends/codehands-docs/blob/main/roadmap/tahira.md).
260+
| `sandbox.unavailable` | Docker Desktop isn't running. Start it and try again |
261+
| `sandbox.failed` | The box broke, not your code. `message` and `output` say why: often a wrong image tag from `.nvmrc`, or no internet for the first image pull |
262+
| "add node_modules/ to .gitignore first" | The sandbox won't install into a repo that doesn't ignore `node_modules`, because it would land in the commit |

‎runner/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"typecheck": "tsc --noEmit",
1919
"test": "vitest run",
2020
"fake": "tsx scripts/fake.ts",
21-
"try": "tsx scripts/try.ts"
21+
"try": "tsx scripts/try.ts",
22+
"sandbox": "tsx scripts/sandbox.ts"
2223
},
2324
"dependencies": {
2425
"@codehands/contracts": "file:../../codehands-contracts",

‎runner/scripts/sandbox.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// pnpm sandbox [repoPath] [--tz <zone>]: runs the repo's tests in the locked box once. No model calls, no cost.
2+
import { randomUUID } from "node:crypto";
3+
import { homedir } from "node:os";
4+
import { join } from "node:path";
5+
import { detectRepoProfile, runInSandbox } from "../src/index.js";
6+
7+
const args = process.argv.slice(2);
8+
const tzAt = args.indexOf("--tz");
9+
const timeZone = tzAt >= 0 ? args[tzAt + 1] : undefined;
10+
const repoPath = args.find((a, i) => !a.startsWith("--") && (tzAt < 0 || i !== tzAt + 1)) ?? join(homedir(), "codehands-fixtures", "date-bug");
11+
12+
const profile = await detectRepoProfile(repoPath);
13+
console.log("profile:", profile);
14+
const r = await runInSandbox({ sessionId: randomUUID(), worktree: repoPath, profile, timeZone });
15+
console.log(r.output.trim().split("\n").slice(-30).join("\n"));
16+
console.log({ passed: r.passed, exitCode: r.exitCode, timedOut: r.timedOut, oomKilled: r.oomKilled, durationMs: r.durationMs, error: r.error, message: r.message, resultFile: r.resultFile });
17+
process.exitCode = r.passed ? 0 : 1;

‎runner/src/index.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
export { createRunner, type RunnerOptions } from "./runner.js";
1+
export { createRunner, type RecoverableRunner, type RecoveryReport, type RunnerOptions } from "./runner.js";
22
export { createFakeRunner, type FakeRunnerOptions } from "./fake-runner.js";
33
export { EventSequence } from "./events.js";
44
export { SessionStore, type SessionRecord } from "./session-store.js";
55
export { branchName, slugify } from "./git/worktree.js";
66
export { createMemoryStub, formatNote, notesForPrompt, runNote, trimNotes, type MemoryNote, type MemoryStore } from "./memory/store.js";
77
export { createFileMemory, repoKey } from "./memory/notes.js";
88
export { detectRepoProfile, type RepoProfile } from "./memory/profile.js";
9+
export { runInSandbox, type SandboxOptions, type SandboxResult, type SandboxError } from "./runtime/sandbox.js";
10+
export { dockerArgs, installArgs, dockerAvailable, hostTimeZone, DEFAULT_LIMITS, type BoxLimits } from "./runtime/docker.js";

0 commit comments

Comments
 (0)