Skip to content

Commit f61669f

Browse files
committed
Keep the opencode sandbox alive past the end of run.mjs
run.mjs printed a preview URL and told you to open it in a browser, then `await using` terminated the sandbox as the scope ended — the URL 404'd before anyone could click it. Caught by running run.mjs for the first time. It now creates the sandbox without `await using`, holds the process open, and disposes on SIGINT, so the URL stays reachable until Ctrl-C. idleTimeoutMinutes: 30 reaps a sandbox that gets forgotten. Holding the loop open needs a ref'd handle: an unsettled top-level await makes Node exit 13 with "Detected unsettled top-level await". Dropping `await using` also drops the Node 24 requirement for this example; run.mjs now parses on Node 22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uz85xGA4zWSge1BtiDNnSN
1 parent 3557317 commit f61669f

2 files changed

Lines changed: 26 additions & 5 deletions

File tree

examples/opencode-sandbox/README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });
1616

1717
// enableOpenCode bakes the CLI into the guest — nothing to npm install.
1818
// openCodeProvider.apiKey lands in the guest as OPENCODE_API_KEY.
19-
await using sandbox = await tenki.createAndWait({
19+
// No `await using` here: it would terminate the sandbox at the end of this scope and the
20+
// URL below would 404 before you could open it. idleTimeoutMinutes caps it instead.
21+
const sandbox = await tenki.createAndWait({
2022
cpuCores: 2,
2123
memoryMb: 4096,
2224
enableOpenCode: true,
2325
openCodeProvider: { apiKey: process.env.OPENAI_API_KEY },
2426
allowInbound: true,
2527
cloneRepoUrl: REPO,
28+
idleTimeoutMinutes: 30,
2629
workspaceId: process.env.TENKI_WORKSPACE_ID,
2730
});
2831

@@ -39,9 +42,17 @@ const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toS
3942

4043
const project = await (await fetch(`${previewUrl}/project/current`, { headers: auth })).json();
4144
console.log(`${project.worktree} (${project.vcs}) is live at ${previewUrl}/app`);
45+
console.log(`user: anything, password: ${PASSWORD}
46+
Ctrl-C to terminate the sandbox.`);
47+
48+
process.on("SIGINT", async () => {
49+
await sandbox[Symbol.asyncDispose]();
50+
process.exit(0);
51+
});
52+
setInterval(() => {}, 1 << 30); // hold the event loop open; an unsettled await would exit 13
4253
```
4354

44-
Open the printed `/app` URL in a browser and you are looking at OpenCode's own UI, driving a repo inside the microVM. The same URL serves the JSON API — `/session`, `/agent`, `/project/current` — so a script can drive it just as easily.
55+
The script stays in the foreground so the URL keeps working. Open the printed `/app` URL in a browser and you are looking at OpenCode's own UI, driving a repo inside the microVM; the same URL serves the JSON API — `/session`, `/agent`, `/project/current` — so a script can drive it just as easily. Ctrl-C terminates the sandbox and the URL with it.
4556

4657
## Run it
4758

@@ -51,10 +62,9 @@ export TENKI_AUTH_TOKEN=... # from `tenki login` (~/.config/tenki/config.ya
5162
export TENKI_WORKSPACE_ID=...
5263
export OPENAI_API_KEY=sk-... # or any provider OpenCode supports, via openCodeProvider
5364
node run.mjs # -> /home/tenki/repo (git) is live at https://....sb.tenki.sh/app
65+
# stays up until you Ctrl-C
5466
```
5567

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

6070
```bash
@@ -63,6 +73,7 @@ node verify.mjs # boot → serve → exposePort → assert 401 unauthed, then
6373

6474
## Notes
6575

76+
- **This example deliberately does not use `await using`.** It would terminate the sandbox at the end of the script's scope, and the URL it just printed would `404` before you could open it — the whole point here is a machine that outlives the script. So it disposes on `SIGINT` instead, and sets `idleTimeoutMinutes: 30` so a forgotten sandbox still reaps itself. Holding the process open needs a ref'd handle (`setInterval`); an unsettled top-level `await` makes Node exit 13 with `Detected unsettled top-level await`.
6677
- **Set `OPENCODE_SERVER_PASSWORD`, always.** Without it the server logs `OPENCODE_SERVER_PASSWORD is not set; server is unsecured` and answers every caller — and `exposePort` has just put it on the public internet, so that is an open coding agent with a shell. With it set, unauthenticated requests get `401`. Auth is HTTP **Basic** (any username, that password); a `Bearer` token is rejected. `verify.mjs` asserts the `401` precisely so this cannot regress unnoticed.
6778
- **Port 7681 is spoken for.** Tenki's own `ttyd` console listens there, and `exposePort(7681)` fails with `[invalid_argument] port 7681 cannot be exposed as a preview`. Pick any other port for your server.
6879
- **`--hostname 0.0.0.0` is required.** `opencode serve` defaults to `127.0.0.1`, which the gateway cannot reach, so the preview URL would just hang.

examples/opencode-sandbox/run.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });
1111

1212
// enableOpenCode bakes the CLI into the guest — nothing to npm install.
1313
// openCodeProvider.apiKey lands in the guest as OPENCODE_API_KEY.
14-
await using sandbox = await tenki.createAndWait({
14+
// No `await using` here: it would terminate the sandbox at the end of this scope and the
15+
// URL below would 404 before you could open it. idleTimeoutMinutes caps it instead.
16+
const sandbox = await tenki.createAndWait({
1517
cpuCores: 2,
1618
memoryMb: 4096,
1719
enableOpenCode: true,
1820
openCodeProvider: { apiKey: process.env.OPENAI_API_KEY },
1921
allowInbound: true,
2022
cloneRepoUrl: REPO,
23+
idleTimeoutMinutes: 30,
2124
workspaceId: process.env.TENKI_WORKSPACE_ID,
2225
});
2326

@@ -34,3 +37,10 @@ const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toS
3437

3538
const project = await (await fetch(`${previewUrl}/project/current`, { headers: auth })).json();
3639
console.log(`${project.worktree} (${project.vcs}) is live at ${previewUrl}/app`);
40+
console.log(`user: anything, password: ${PASSWORD}\nCtrl-C to terminate the sandbox.`);
41+
42+
process.on("SIGINT", async () => {
43+
await sandbox[Symbol.asyncDispose]();
44+
process.exit(0);
45+
});
46+
setInterval(() => {}, 1 << 30); // hold the event loop open; an unsettled await would exit 13

0 commit comments

Comments
 (0)