Skip to content

Commit 9c96f35

Browse files
committed
Add claude-code-sandbox example
Runs Claude Code headless against a real Git checkout inside a disposable Tenki microVM, then reads back the diff the agent produced. verify.mjs proves the Tenki-facing half with no model key: boot with cloneRepoUrl, npm i -g the CLI, assert its version, edit a file in the checkout, assert the diff round-trips. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uz85xGA4zWSge1BtiDNnSN
1 parent 1c86a86 commit 9c96f35

5 files changed

Lines changed: 176 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Give an agent a sandboxed place to run the code it writes.
5959
| Example | Integration |
6060
| --- | --- |
6161
| [MCP server](examples/mcp-tenki-sandbox/) | Sandbox tools for Claude, Cursor, or any MCP client |
62+
| [Claude Code](examples/claude-code-sandbox/) | Headless coding agent on a real repo checkout |
6263
| [Composio](examples/composio-tenki/) | Tenki tools in a Composio agent |
6364
| [Covalent](examples/covalent-tenki/) | Each workflow task in its own microVM |
6465
| [AgentBox](examples/agentbox-tenki/) | Coding-agent boxes as Firecracker microVMs |
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Claude Code on Tenki (headless, on a real repo)
2+
3+
Run Claude Code non-interactively against a real Git checkout inside a disposable [Tenki Sandbox](https://tenki.cloud/products/sandbox) microVM, then read back the diff the agent produced and throw the machine away.
4+
5+
## The code (`run.mjs`)
6+
7+
```js
8+
import { TenkiSandbox, stdoutText } from "@tenkicloud/sandbox";
9+
10+
const REPO = "https://github.com/sindresorhus/yocto-queue";
11+
const TASK = "Add a toArray() method to the Queue class in index.js that returns the queued values as an array, oldest first. Declare it in index.d.ts and add a test for it in test.js.";
12+
const PASSTHROUGH = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL"];
13+
14+
const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });
15+
16+
// cloneRepoUrl checks the repo out to ./repo before createAndWait resolves.
17+
await using sandbox = await tenki.createAndWait({
18+
cpuCores: 2,
19+
memoryMb: 4096,
20+
cloneRepoUrl: REPO,
21+
workspaceId: process.env.TENKI_WORKSPACE_ID,
22+
});
23+
24+
// Outbound is on by default, so no allowOutbound is needed to reach the npm registry.
25+
await sandbox.exec("npm", { args: ["i", "-g", "@anthropic-ai/claude-code"] });
26+
27+
const decoder = new TextDecoder();
28+
await sandbox.exec("claude", {
29+
// Skipping permission prompts is what the throwaway VM buys you: the agent gets a
30+
// free hand on a machine whose entire filesystem you delete at the end of this script.
31+
args: ["-p", TASK, "--dangerously-skip-permissions"],
32+
cwd: "repo", // relative paths resolve under the workdir, /home/tenki
33+
timeoutMs: 10 * 60_000,
34+
env: Object.fromEntries(PASSTHROUGH.filter((k) => process.env[k]).map((k) => [k, process.env[k]])),
35+
onOutput: ({ data }) => process.stdout.write(decoder.decode(data)), // data is a Uint8Array
36+
});
37+
38+
// sandbox.git.* runs at the workdir, and the checkout is one level down — so use `git -C`.
39+
console.log(stdoutText(await sandbox.exec("git", { args: ["-C", "repo", "diff"] })));
40+
```
41+
42+
The last line is the point: a unified diff against a real upstream checkout, produced by an agent that had root-free run of a machine you are about to delete.
43+
44+
## Run it
45+
46+
```bash
47+
npm install
48+
export TENKI_AUTH_TOKEN=... # from `tenki login` (~/.config/tenki/config.yaml)
49+
export TENKI_WORKSPACE_ID=...
50+
export ANTHROPIC_API_KEY=... # the agent turn; ANTHROPIC_MODEL is optional
51+
node run.mjs # streams the agent's turn, then prints the diff
52+
```
53+
54+
Verify the Tenki half without a model key — this is what CI runs:
55+
56+
```bash
57+
node verify.mjs # create + clone → install the CLI → read its version → edit → git diff
58+
```
59+
60+
## Notes
61+
62+
- **`sandbox.git.*` runs at the sandbox workdir (`/home/tenki`), but `cloneRepoUrl` checks out one level down into `./repo`** — so `sandbox.git.diff()` fails with "not a git repository". Read the checkout with `sandbox.exec("git", { args: ["-C", "repo", "diff"] })`, or clone at the workdir root if you want the helpers.
63+
- The default image already ships Node 24, npm 11, and git 2.43, and outbound network is on for a bare `create`. `npm i -g @anthropic-ai/claude-code` finishes in about five seconds — no `allowOutbound`, no custom image, no baked-in CLI.
64+
- `--dangerously-skip-permissions` refuses to run as root; the sandbox user is `tenki`, so it works as written. It is the right flag *here* precisely because the blast radius is one microVM.
65+
- `exec(command, { args })` runs a bare binary and its args with no shell splitting, so the whole task prompt goes through as a single argument. `ExecOptions.env` is scoped to that one process — the model key never lands in the repo or the image.
66+
- Secrets in, diffs out: nothing else from your shell crosses into the VM, and `await using` terminates it when the scope ends (`Session` is an `AsyncDisposable`). Requires Node 20+.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "claude-code-sandbox",
3+
"private": true,
4+
"type": "module",
5+
"description": "Cookbook example — run Claude Code headless against a real repo in a disposable Tenki sandbox.",
6+
"dependencies": {
7+
"@tenkicloud/sandbox": "^0.4.0"
8+
}
9+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Claude Code, headless, on a real repo in a disposable Tenki sandbox:
2+
// clone -> install the CLI -> let the agent edit the checkout -> read back the diff.
3+
import { TenkiSandbox, stdoutText } from "@tenkicloud/sandbox";
4+
5+
const REPO = "https://github.com/sindresorhus/yocto-queue";
6+
const TASK = "Add a toArray() method to the Queue class in index.js that returns the queued values as an array, oldest first. Declare it in index.d.ts and add a test for it in test.js.";
7+
const PASSTHROUGH = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL"];
8+
9+
const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });
10+
11+
// cloneRepoUrl checks the repo out to ./repo before createAndWait resolves.
12+
await using sandbox = await tenki.createAndWait({
13+
cpuCores: 2,
14+
memoryMb: 4096,
15+
cloneRepoUrl: REPO,
16+
workspaceId: process.env.TENKI_WORKSPACE_ID,
17+
});
18+
19+
// Outbound is on by default, so no allowOutbound is needed to reach the npm registry.
20+
await sandbox.exec("npm", { args: ["i", "-g", "@anthropic-ai/claude-code"] });
21+
22+
const decoder = new TextDecoder();
23+
await sandbox.exec("claude", {
24+
// Skipping permission prompts is what the throwaway VM buys you: the agent gets a
25+
// free hand on a machine whose entire filesystem you delete at the end of this script.
26+
args: ["-p", TASK, "--dangerously-skip-permissions"],
27+
cwd: "repo", // relative paths resolve under the workdir, /home/tenki
28+
timeoutMs: 10 * 60_000,
29+
env: Object.fromEntries(PASSTHROUGH.filter((k) => process.env[k]).map((k) => [k, process.env[k]])),
30+
onOutput: ({ data }) => process.stdout.write(decoder.decode(data)), // data is a Uint8Array
31+
});
32+
33+
// sandbox.git.* runs at the workdir, and the checkout is one level down — so use `git -C`.
34+
console.log(stdoutText(await sandbox.exec("git", { args: ["-C", "repo", "diff"] })));
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* Proves the Tenki-facing half of this example without a model key: boot a sandbox
3+
* with the repo cloned, install the Claude Code CLI, read its version back, edit a
4+
* file in the checkout, and assert the diff round-trips. (run.mjs's agent turn needs
5+
* ANTHROPIC_API_KEY; CONTRIBUTING says CI verifies the backend, not the model.)
6+
* Token/workspace from env (CI) or ~/.config/tenki/config.yaml (local `tenki login`).
7+
*/
8+
import { TenkiSandbox, stdoutText } from "@tenkicloud/sandbox";
9+
import { readFileSync } from "node:fs";
10+
import { homedir } from "node:os";
11+
12+
const cfg = (key) => {
13+
try {
14+
const c = readFileSync(`${homedir()}/.config/tenki/config.yaml`, "utf8");
15+
return (c.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1] ?? "").trim();
16+
} catch {
17+
return "";
18+
}
19+
};
20+
21+
const authToken = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY || cfg("auth_token");
22+
const workspaceId = process.env.TENKI_WORKSPACE_ID || cfg("current_workspace_id");
23+
if (!authToken) {
24+
console.error("No token. Set TENKI_AUTH_TOKEN, or run `tenki login`.");
25+
process.exit(1);
26+
}
27+
28+
const tenki = new TenkiSandbox({ authToken });
29+
let sandbox;
30+
try {
31+
sandbox = await tenki.createAndWait({
32+
cpuCores: 2,
33+
memoryMb: 4096,
34+
cloneRepoUrl: "https://github.com/sindresorhus/yocto-queue",
35+
workspaceId,
36+
});
37+
38+
const pkg = JSON.parse(stdoutText(await sandbox.exec("cat", { args: ["repo/package.json"] })));
39+
if (pkg.name !== "yocto-queue") throw new Error(`clone landed wrong: repo/package.json is ${pkg.name}`);
40+
41+
const install = await sandbox.exec("npm", { args: ["i", "-g", "@anthropic-ai/claude-code"], timeoutMs: 300_000 });
42+
if (install.exitCode !== 0) throw new Error(`npm i -g @anthropic-ai/claude-code exited ${install.exitCode}`);
43+
44+
const version = stdoutText(await sandbox.exec("claude", { args: ["--version"] })).trim();
45+
if (!/^\d+\.\d+\.\d+ \(Claude Code\)$/.test(version)) throw new Error(`claude --version said ${JSON.stringify(version)}`);
46+
47+
// Stand in for the agent's edit, then read it back the way run.mjs reads the agent's.
48+
await sandbox.exec("sh", { args: ["-c", "printf '\\nexport const verified = true;\\n' >> repo/index.js"] });
49+
const diff = stdoutText(await sandbox.exec("git", { args: ["-C", "repo", "diff"] }));
50+
if (!diff.includes("--- a/index.js") || !diff.includes("+export const verified = true;")) {
51+
throw new Error(`diff did not round-trip: ${JSON.stringify(diff.slice(0, 200))}`);
52+
}
53+
54+
console.log(`✓ claude-code-sandbox: create + clone → npm i -g claude-code → ${version} → edit → git diff → dispose`);
55+
} catch (e) {
56+
console.error("✗ " + (e?.message ?? e));
57+
process.exitCode = 1;
58+
} finally {
59+
if (sandbox) {
60+
try {
61+
await sandbox[Symbol.asyncDispose]();
62+
} catch {
63+
/* self-reaps via idle/lifetime caps */
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)