|
| 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+. |
0 commit comments