Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Give an agent a sandboxed place to run the code it writes.
| [MCP server](examples/mcp-tenki-sandbox/) | Sandbox tools for Claude, Cursor, or any MCP client |
| [Claude Code](examples/claude-code-sandbox/) | Headless coding agent on a real repo checkout |
| [Codex](examples/codex-sandbox/) | OpenAI's coding agent, headless, on a real repo checkout |
| [OpenCode](examples/opencode-sandbox/) | Headless OpenCode server on a public preview URL |
| [Composio](examples/composio-tenki/) | Tenki tools in a Composio agent |
| [Covalent](examples/covalent-tenki/) | Each workflow task in its own microVM |
| [AgentBox](examples/agentbox-tenki/) | Coding-agent boxes as Firecracker microVMs |
Expand Down
82 changes: 82 additions & 0 deletions examples/opencode-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# OpenCode on Tenki (headless server on a public URL)

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.

## The code (`run.mjs`)

```js
import { TenkiSandbox } from "@tenkicloud/sandbox";
import { randomBytes } from "node:crypto";

const REPO = "https://github.com/sindresorhus/yocto-queue";
const PORT = 4096;
const PASSWORD = randomBytes(24).toString("hex"); // this URL is public; see the note below

const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });

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

// --hostname 0.0.0.0 is load-bearing: the default 127.0.0.1 is unreachable from the gateway.
// setsid + redirected stdio keeps the server running after this exec() returns.
await sandbox.exec("sh", {
args: ["-c", `cd repo && setsid opencode serve --port ${PORT} --hostname 0.0.0.0 >/tmp/opencode.log 2>&1 </dev/null & sleep 5`],
env: { OPENCODE_SERVER_PASSWORD: PASSWORD },
});

const { previewUrl } = await sandbox.exposePort(PORT);
// Auth is HTTP Basic — any username, the password above. A Bearer token is rejected.
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toString("base64") };

const project = await (await fetch(`${previewUrl}/project/current`, { headers: auth })).json();
console.log(`${project.worktree} (${project.vcs}) is live at ${previewUrl}/app`);
console.log(`user: anything, password: ${PASSWORD}
Ctrl-C to terminate the sandbox.`);

process.on("SIGINT", async () => {
await sandbox[Symbol.asyncDispose]();
process.exit(0);
});
setInterval(() => {}, 1 << 30); // hold the event loop open; an unsettled await would exit 13
```

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.

## Run it

```bash
npm install
export TENKI_AUTH_TOKEN=... # from `tenki login` (~/.config/tenki/config.yaml)
export TENKI_WORKSPACE_ID=...
export OPENAI_API_KEY=sk-... # or any provider OpenCode supports, via openCodeProvider
node run.mjs # -> /home/tenki/repo (git) is live at https://....sb.tenki.sh/app
# stays up until you Ctrl-C
```

Verify the Tenki half without a model key — this is what CI runs:

```bash
node verify.mjs # boot → serve → exposePort → assert 401 unauthed, then 200 + the checkout
```

## Notes

- **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`.
- **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.
- **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.
- **`--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.
- `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`.
- `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.
- 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/).
9 changes: 9 additions & 0 deletions examples/opencode-sandbox/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "opencode-sandbox",
"private": true,
"type": "module",
"description": "Cookbook example — a browser-accessible OpenCode session in a disposable Tenki sandbox.",
"dependencies": {
"@tenkicloud/sandbox": "^0.4.0"
}
}
46 changes: 46 additions & 0 deletions examples/opencode-sandbox/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// A headless OpenCode server in a disposable Tenki sandbox, reachable over HTTPS:
// boot with the agent and a repo already inside, serve, expose, then drive it from here.
import { TenkiSandbox } from "@tenkicloud/sandbox";
import { randomBytes } from "node:crypto";

const REPO = "https://github.com/sindresorhus/yocto-queue";
const PORT = 4096;
const PASSWORD = randomBytes(24).toString("hex"); // this URL is public; see the note below

const tenki = new TenkiSandbox({ authToken: process.env.TENKI_AUTH_TOKEN });

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

// --hostname 0.0.0.0 is load-bearing: the default 127.0.0.1 is unreachable from the gateway.
// setsid + redirected stdio keeps the server running after this exec() returns.
await sandbox.exec("sh", {
args: ["-c", `cd repo && setsid opencode serve --port ${PORT} --hostname 0.0.0.0 >/tmp/opencode.log 2>&1 </dev/null & sleep 5`],
env: { OPENCODE_SERVER_PASSWORD: PASSWORD },
});

const { previewUrl } = await sandbox.exposePort(PORT);
// Auth is HTTP Basic — any username, the password above. A Bearer token is rejected.
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toString("base64") };

const project = await (await fetch(`${previewUrl}/project/current`, { headers: auth })).json();
console.log(`${project.worktree} (${project.vcs}) is live at ${previewUrl}/app`);
console.log(`user: anything, password: ${PASSWORD}\nCtrl-C to terminate the sandbox.`);

process.on("SIGINT", async () => {
await sandbox[Symbol.asyncDispose]();
process.exit(0);
});
setInterval(() => {}, 1 << 30); // hold the event loop open; an unsettled await would exit 13
75 changes: 75 additions & 0 deletions examples/opencode-sandbox/verify.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Proves the Tenki-facing half of this example without a model key: boot a sandbox with
* OpenCode baked in and a repo cloned, start the headless server, expose it, and assert the
* public URL is both password-protected and serving the checkout. (Driving the agent needs
* a model key; CONTRIBUTING says CI verifies the backend, not the model.)
* Token/workspace from env (CI) or ~/.config/tenki/config.yaml (local `tenki login`).
*/
import { TenkiSandbox, stdoutText } from "@tenkicloud/sandbox";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { randomBytes } from "node:crypto";

const cfg = (key) => {
try {
const c = readFileSync(`${homedir()}/.config/tenki/config.yaml`, "utf8");
return (c.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1] ?? "").trim();
} catch {
return "";
}
};

const authToken = process.env.TENKI_AUTH_TOKEN || process.env.TENKI_API_KEY || cfg("auth_token");
const workspaceId = process.env.TENKI_WORKSPACE_ID || cfg("current_workspace_id");
if (!authToken) {
console.error("No token. Set TENKI_AUTH_TOKEN, or run `tenki login`.");
process.exit(1);
}

const PORT = 4096;
const PASSWORD = randomBytes(24).toString("hex");
const tenki = new TenkiSandbox({ authToken });
let sandbox;
try {
const cloneRepoUrl = "https://github.com/sindresorhus/yocto-queue";
sandbox = await tenki.createAndWait({ cpuCores: 2, memoryMb: 4096, enableOpenCode: true, allowInbound: true, cloneRepoUrl, workspaceId });

// enableOpenCode should have baked the CLI in — no install step anywhere in this script.
const version = stdoutText(await sandbox.exec("opencode", { args: ["--version"] })).trim();
if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`opencode --version said ${JSON.stringify(version)}`);

await sandbox.exec("sh", {
args: ["-c", `cd repo && setsid opencode serve --port ${PORT} --hostname 0.0.0.0 >/tmp/opencode.log 2>&1 </dev/null & sleep 5`],
env: { OPENCODE_SERVER_PASSWORD: PASSWORD },
});

const { previewUrl } = await sandbox.exposePort(PORT);
const auth = { Authorization: "Basic " + Buffer.from(`opencode:${PASSWORD}`).toString("base64") };

let project;
for (let i = 0; i < 15; i++) {
try {
const res = await fetch(`${previewUrl}/project/current`, { redirect: "follow", headers: auth });
if (res.status === 200) { project = await res.json(); break; }
} catch { /* gateway warming up */ }
await new Promise((r) => setTimeout(r, 2000));
}
if (project?.worktree !== "/home/tenki/repo" || project?.vcs !== "git") throw new Error(`unexpected project: ${JSON.stringify(project)}`);

// The URL is public, so an unauthenticated caller must be turned away.
const open = (await fetch(`${previewUrl}/project/current`, { redirect: "follow" })).status;
if (open !== 401) throw new Error(`unauthenticated request returned ${open}, expected 401`);

console.log(`✓ opencode-sandbox: create + clone → opencode ${version} serve → exposePort → 401 unauthed, 200 → ${project.worktree} → dispose`);
} catch (e) {
console.error("✗ " + (e?.message ?? e));
process.exitCode = 1;
} finally {
if (sandbox) {
try {
await sandbox[Symbol.asyncDispose]();
} catch {
/* self-reaps via idle/lifetime caps */
}
}
}
Loading