Skip to content

Commit 3557317

Browse files
committed
Add opencode-sandbox example
Documents enableOpenCode, a first-party Tenki feature the cookbook had no coverage of: it bakes the OpenCode CLI into the guest before the sandbox reports ready, so this example has no install step at all. Runs `opencode serve` against a cloned repo and exposes it on a public preview URL, so the same URL is both OpenCode's web UI (/app) and its JSON API. Measured gotchas are documented: port 7681 is reserved by Tenki's own ttyd console and cannot be exposed, --hostname 0.0.0.0 is required, and OPENCODE_SERVER_PASSWORD must be set or the server is open to the internet. verify.mjs asserts an unauthenticated request gets 401 before asserting the authenticated one returns the checkout, so the example cannot regress into publishing an unsecured agent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uz85xGA4zWSge1BtiDNnSN
1 parent 097e457 commit 3557317

5 files changed

Lines changed: 192 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ Give an agent a sandboxed place to run the code it writes.
6262
| [MCP server](examples/mcp-tenki-sandbox/) | Sandbox tools for Claude, Cursor, or any MCP client |
6363
| [Claude Code](examples/claude-code-sandbox/) | Headless coding agent on a real repo checkout |
6464
| [Codex](examples/codex-sandbox/) | OpenAI's coding agent, headless, on a real repo checkout |
65+
| [OpenCode](examples/opencode-sandbox/) | Headless OpenCode server on a public preview URL |
6566
| [Composio](examples/composio-tenki/) | Tenki tools in a Composio agent |
6667
| [Covalent](examples/covalent-tenki/) | Each workflow task in its own microVM |
6768
| [AgentBox](examples/agentbox-tenki/) | Coding-agent boxes as Firecracker microVMs |
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# OpenCode on Tenki (headless server on a public URL)
2+
3+
Boot a disposable [Tenki Sandbox](https://tenki.cloud/products/sandbox) microVM with [OpenCode](https://opencode.ai) and a Git checkout already inside it, start the agent's headless server, and expose it on a public HTTPS URL — a coding agent, and its web UI, running on a machine you can throw away. No install step: `enableOpenCode` bakes the CLI into the guest.
4+
5+
## The code (`run.mjs`)
6+
7+
```js
8+
import { TenkiSandbox } from "@tenkicloud/sandbox";
9+
import { randomBytes } from "node:crypto";
10+
11+
const REPO = "https://github.com/sindresorhus/yocto-queue";
12+
const PORT = 4096;
13+
const PASSWORD = randomBytes(24).toString("hex"); // this URL is public; see the note below
14+
15+
const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });
16+
17+
// enableOpenCode bakes the CLI into the guest — nothing to npm install.
18+
// openCodeProvider.apiKey lands in the guest as OPENCODE_API_KEY.
19+
await using sandbox = await tenki.createAndWait({
20+
cpuCores: 2,
21+
memoryMb: 4096,
22+
enableOpenCode: true,
23+
openCodeProvider: { apiKey: process.env.OPENAI_API_KEY },
24+
allowInbound: true,
25+
cloneRepoUrl: REPO,
26+
workspaceId: process.env.TENKI_WORKSPACE_ID,
27+
});
28+
29+
// --hostname 0.0.0.0 is load-bearing: the default 127.0.0.1 is unreachable from the gateway.
30+
// setsid + redirected stdio keeps the server running after this exec() returns.
31+
await sandbox.exec("sh", {
32+
args: ["-c", `cd repo && setsid opencode serve --port ${PORT} --hostname 0.0.0.0 >/tmp/opencode.log 2>&1 </dev/null & sleep 5`],
33+
env: { OPENCODE_SERVER_PASSWORD: PASSWORD },
34+
});
35+
36+
const { previewUrl } = await sandbox.exposePort(PORT);
37+
// Auth is HTTP Basic — any username, the password above. A Bearer token is rejected.
38+
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toString("base64") };
39+
40+
const project = await (await fetch(`${previewUrl}/project/current`, { headers: auth })).json();
41+
console.log(`${project.worktree} (${project.vcs}) is live at ${previewUrl}/app`);
42+
```
43+
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.
45+
46+
## Run it
47+
48+
```bash
49+
npm install
50+
export TENKI_AUTH_TOKEN=... # from `tenki login` (~/.config/tenki/config.yaml)
51+
export TENKI_WORKSPACE_ID=...
52+
export OPENAI_API_KEY=sk-... # or any provider OpenCode supports, via openCodeProvider
53+
node run.mjs # -> /home/tenki/repo (git) is live at https://....sb.tenki.sh/app
54+
```
55+
56+
`run.mjs` uses top-level `await using`, which needs Node 24+.
57+
58+
Verify the Tenki half without a model key — this is what CI runs:
59+
60+
```bash
61+
node verify.mjs # boot → serve → exposePort → assert 401 unauthed, then 200 + the checkout
62+
```
63+
64+
## Notes
65+
66+
- **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.
67+
- **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.
68+
- **`--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.
69+
- `enableOpenCode: true` puts `opencode` (1.17.20 at the time of writing) at `/usr/local/bin/opencode` before the sandbox reports ready — no `npm install`, no custom image. `openCodeProvider.apiKey` and `.baseUrl` arrive in the guest as `OPENCODE_API_KEY` and `OPENCODE_PROVIDER_BASE_URL`.
70+
- `cloneRepoUrl` checks out to `./repo`, so the server is started with `cd repo` — that is what makes `/project/current` report `worktree: /home/tenki/repo` with `vcs: git` instead of an empty directory.
71+
- For the same agent driven as a one-shot CLI instead of a server, `opencode run "<task>"` works too — the shape used by [claude-code-sandbox](../claude-code-sandbox/) and [codex-sandbox](../codex-sandbox/).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "opencode-sandbox",
3+
"private": true,
4+
"type": "module",
5+
"description": "Cookbook example — a browser-accessible OpenCode session in a disposable Tenki sandbox.",
6+
"dependencies": {
7+
"@tenkicloud/sandbox": "^0.4.0"
8+
}
9+
}

examples/opencode-sandbox/run.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// A headless OpenCode server in a disposable Tenki sandbox, reachable over HTTPS:
2+
// boot with the agent and a repo already inside, serve, expose, then drive it from here.
3+
import { TenkiSandbox } from "@tenkicloud/sandbox";
4+
import { randomBytes } from "node:crypto";
5+
6+
const REPO = "https://github.com/sindresorhus/yocto-queue";
7+
const PORT = 4096;
8+
const PASSWORD = randomBytes(24).toString("hex"); // this URL is public; see the note below
9+
10+
const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });
11+
12+
// enableOpenCode bakes the CLI into the guest — nothing to npm install.
13+
// openCodeProvider.apiKey lands in the guest as OPENCODE_API_KEY.
14+
await using sandbox = await tenki.createAndWait({
15+
cpuCores: 2,
16+
memoryMb: 4096,
17+
enableOpenCode: true,
18+
openCodeProvider: { apiKey: process.env.OPENAI_API_KEY },
19+
allowInbound: true,
20+
cloneRepoUrl: REPO,
21+
workspaceId: process.env.TENKI_WORKSPACE_ID,
22+
});
23+
24+
// --hostname 0.0.0.0 is load-bearing: the default 127.0.0.1 is unreachable from the gateway.
25+
// setsid + redirected stdio keeps the server running after this exec() returns.
26+
await sandbox.exec("sh", {
27+
args: ["-c", `cd repo && setsid opencode serve --port ${PORT} --hostname 0.0.0.0 >/tmp/opencode.log 2>&1 </dev/null & sleep 5`],
28+
env: { OPENCODE_SERVER_PASSWORD: PASSWORD },
29+
});
30+
31+
const { previewUrl } = await sandbox.exposePort(PORT);
32+
// Auth is HTTP Basic — any username, the password above. A Bearer token is rejected.
33+
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toString("base64") };
34+
35+
const project = await (await fetch(`${previewUrl}/project/current`, { headers: auth })).json();
36+
console.log(`${project.worktree} (${project.vcs}) is live at ${previewUrl}/app`);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Proves the Tenki-facing half of this example without a model key: boot a sandbox with
3+
* OpenCode baked in and a repo cloned, start the headless server, expose it, and assert the
4+
* public URL is both password-protected and serving the checkout. (Driving the agent needs
5+
* a model 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+
import { randomBytes } from "node:crypto";
12+
13+
const cfg = (key) => {
14+
try {
15+
const c = readFileSync(`${homedir()}/.config/tenki/config.yaml`, "utf8");
16+
return (c.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1] ?? "").trim();
17+
} catch {
18+
return "";
19+
}
20+
};
21+
22+
const authToken = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY || cfg("auth_token");
23+
const workspaceId = process.env.TENKI_WORKSPACE_ID || cfg("current_workspace_id");
24+
if (!authToken) {
25+
console.error("No token. Set TENKI_AUTH_TOKEN, or run `tenki login`.");
26+
process.exit(1);
27+
}
28+
29+
const PORT = 4096;
30+
const PASSWORD = randomBytes(24).toString("hex");
31+
const tenki = new TenkiSandbox({ authToken });
32+
let sandbox;
33+
try {
34+
const cloneRepoUrl = "https://github.com/sindresorhus/yocto-queue";
35+
sandbox = await tenki.createAndWait({ cpuCores: 2, memoryMb: 4096, enableOpenCode: true, allowInbound: true, cloneRepoUrl, workspaceId });
36+
37+
// enableOpenCode should have baked the CLI in — no install step anywhere in this script.
38+
const version = stdoutText(await sandbox.exec("opencode", { args: ["--version"] })).trim();
39+
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`opencode --version said ${JSON.stringify(version)}`);
40+
41+
await sandbox.exec("sh", {
42+
args: ["-c", `cd repo && setsid opencode serve --port ${PORT} --hostname 0.0.0.0 >/tmp/opencode.log 2>&1 </dev/null & sleep 5`],
43+
env: { OPENCODE_SERVER_PASSWORD: PASSWORD },
44+
});
45+
46+
const { previewUrl } = await sandbox.exposePort(PORT);
47+
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toString("base64") };
48+
49+
let project;
50+
for (let i = 0; i < 15; i++) {
51+
try {
52+
const res = await fetch(`${previewUrl}/project/current`, { redirect: "follow", headers: auth });
53+
if (res.status === 200) { project = await res.json(); break; }
54+
} catch { /* gateway warming up */ }
55+
await new Promise((r) => setTimeout(r, 2000));
56+
}
57+
if (project?.worktree !== "/home/tenki/repo" || project?.vcs !== "git") throw new Error(`unexpected project: ${JSON.stringify(project)}`);
58+
59+
// The URL is public, so an unauthenticated caller must be turned away.
60+
const open = (await fetch(`${previewUrl}/project/current`, { redirect: "follow" })).status;
61+
if (open !== 401) throw new Error(`unauthenticated request returned ${open}, expected 401`);
62+
63+
console.log(`✓ opencode-sandbox: create + clone → opencode ${version} serve → exposePort → 401 unauthed, 200 → ${project.worktree} → dispose`);
64+
} catch (e) {
65+
console.error("✗ " + (e?.message ?? e));
66+
process.exitCode = 1;
67+
} finally {
68+
if (sandbox) {
69+
try {
70+
await sandbox[Symbol.asyncDispose]();
71+
} catch {
72+
/* self-reaps via idle/lifetime caps */
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)