Skip to content

Commit 7948e15

Browse files
committed
Assert run.mjs's CLI flags and document the empty-env gotcha
verify.mjs now checks `claude --help` still lists `-p, --print` and `--dangerously-skip-permissions`, since npm installs whatever version is latest at run time. An ExecOptions.env value of undefined arrives in the guest as an empty string rather than unset, which is why run.mjs filters the passthrough list; measured, and now stated where it matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uz85xGA4zWSge1BtiDNnSN
1 parent 84f1fd7 commit 7948e15

3 files changed

Lines changed: 20 additions & 13 deletions

File tree

examples/claude-code-sandbox/README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ await sandbox.exec("claude", {
3131
args: ["-p", TASK, "--dangerously-skip-permissions"],
3232
cwd: "repo", // relative paths resolve under the workdir, /home/tenki
3333
timeoutMs: 10 * 60_000,
34+
// An env value of undefined arrives as an empty string, not as unset — an empty
35+
// ANTHROPIC_BASE_URL is worse than none, so filter to the vars you actually have.
3436
env: Object.fromEntries(PASSTHROUGH.filter((k) => process.env[k]).map((k) => [k, process.env[k]])),
3537
onOutput: ({ data }) => process.stdout.write(decoder.decode(data)), // data is a Uint8Array
3638
});
@@ -47,20 +49,22 @@ The last line is the point: a unified diff against a real upstream checkout, pro
4749
npm install
4850
export TENKI_AUTH_TOKEN=... # from `tenki login` (~/.config/tenki/config.yaml)
4951
export TENKI_WORKSPACE_ID=...
50-
export ANTHROPIC_API_KEY=... # the agent turn; ANTHROPIC_MODEL is optional
52+
export ANTHROPIC_API_KEY=... # ANTHROPIC_BASE_URL and ANTHROPIC_MODEL pass through too, if set
5153
node run.mjs # streams the agent's turn, then prints the diff
5254
```
5355

56+
`run.mjs` uses top-level `await using`, which needs Node 24+.
57+
5458
Verify the Tenki half without a model key — this is what CI runs:
5559

5660
```bash
57-
node verify.mjs # create + clone → install the CLI → read its version → edit → git diff
61+
node verify.mjs # create + clone → install the CLI → check its version and flags → edit → git diff
5862
```
5963

6064
## Notes
6165

6266
- **`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.
6367
- 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.
6468
- `--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.
69+
- `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. A key whose value is `undefined` arrives as an *empty string* rather than unset, which is why `run.mjs` filters before building the env.
6670
- 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`). Top-level `await using` needs **Node 24+** — Node 22 fails to parse it. `verify.mjs` uses `try`/`finally` instead, so CI runs fine on Node 20.

examples/claude-code-sandbox/run.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ await sandbox.exec("claude", {
2626
args: ["-p", TASK, "--dangerously-skip-permissions"],
2727
cwd: "repo", // relative paths resolve under the workdir, /home/tenki
2828
timeoutMs: 10 * 60_000,
29+
// An env value of undefined arrives as an empty string, not as unset — an empty
30+
// ANTHROPIC_BASE_URL is worse than none, so filter to the vars you actually have.
2931
env: Object.fromEntries(PASSTHROUGH.filter((k) => process.env[k]).map((k) => [k, process.env[k]])),
3032
onOutput: ({ data }) => process.stdout.write(decoder.decode(data)), // data is a Uint8Array
3133
});

examples/claude-code-sandbox/verify.mjs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
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.)
2+
* Proves the Tenki-facing half of this example without a model key: boot a sandbox with
3+
* the repo cloned, install the Claude Code CLI, check its version and run.mjs's flags,
4+
* edit a file in the checkout, assert the diff round-trips. (The agent turn needs a
5+
* model key; CONTRIBUTING says CI verifies the backend, not the model.)
66
* Token/workspace from env (CI) or ~/.config/tenki/config.yaml (local `tenki login`).
77
*/
88
import { TenkiSandbox, stdoutText } from "@tenkicloud/sandbox";
@@ -28,12 +28,8 @@ if (!authToken) {
2828
const tenki = new TenkiSandbox({ authToken });
2929
let sandbox;
3030
try {
31-
sandbox = await tenki.createAndWait({
32-
cpuCores: 2,
33-
memoryMb: 4096,
34-
cloneRepoUrl: "https://github.com/sindresorhus/yocto-queue",
35-
workspaceId,
36-
});
31+
const cloneRepoUrl = "https://github.com/sindresorhus/yocto-queue";
32+
sandbox = await tenki.createAndWait({ cpuCores: 2, memoryMb: 4096, cloneRepoUrl, workspaceId });
3733

3834
const pkg = JSON.parse(stdoutText(await sandbox.exec("cat", { args: ["repo/package.json"] })));
3935
if (pkg.name !== "yocto-queue") throw new Error(`clone landed wrong: repo/package.json is ${pkg.name}`);
@@ -44,6 +40,11 @@ try {
4440
const version = stdoutText(await sandbox.exec("claude", { args: ["--version"] })).trim();
4541
if (!/^\d+\.\d+\.\d+ \(Claude Code\)$/.test(version)) throw new Error(`claude --version said ${JSON.stringify(version)}`);
4642

43+
// npm always installs the latest CLI, so check run.mjs's flags still exist in it.
44+
const help = stdoutText(await sandbox.exec("claude", { args: ["--help"] }));
45+
const missing = ["-p, --print", "--dangerously-skip-permissions"].filter((f) => !help.includes(f));
46+
if (missing.length) throw new Error(`claude --help no longer lists ${missing.join(", ")}`);
47+
4748
// Stand in for the agent's edit, then read it back the way run.mjs reads the agent's.
4849
await sandbox.exec("sh", { args: ["-c", "printf '\\nexport const verified = true;\\n' >> repo/index.js"] });
4950
const diff = stdoutText(await sandbox.exec("git", { args: ["-C", "repo", "diff"] }));

0 commit comments

Comments
 (0)