Skip to content
Open
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 @@ -71,6 +71,7 @@ Run a self-hosted agent platform's sandboxes on Tenki.
| Example | Platform |
| --- | --- |
| [DeerFlow](examples/deerflow-tenki/) | Community sandbox provider for ByteDance's SuperAgent harness |
| [OpenClaw](examples/openclaw-tenki/) | Sandbox backend for the personal AI assistant |

### Migrating from another provider

Expand Down
61 changes: 61 additions & 0 deletions examples/openclaw-tenki/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# OpenClaw agents on Tenki sandboxes

[OpenClaw](https://github.com/openclaw/openclaw) is an open-source personal AI assistant you run yourself, with plugins and skills distributed through [ClawHub](https://clawhub.ai). The [`@tenkicloud/openclaw`](https://www.npmjs.com/package/@tenkicloud/openclaw) plugin ([LuxorLabs/tenki-openclaw](https://github.com/LuxorLabs/tenki-openclaw)) is a sandbox **backend**: with `sandbox.mode: "all"`, every agent tool execution — shell commands, the filesystem bridge, interactive PTY sessions — runs inside a [Tenki](https://tenki.cloud) Firecracker microVM instead of on your machine.

Each sandbox scope gets one session, found-or-created by a runtime tag (`oc-tenki-<slug>-<hash>`, plus `openclaw` on all of them): existing sessions are reused, `PAUSED` ones are resumed, and an idle session that pauses stops billing until the agent comes back.

## Set it up

```bash
openclaw plugins install clawhub:@tenkicloud/openclaw
export TENKI_AUTH_TOKEN=tk_... # or TENKI_API_KEY; env wins over config
```

(Or from a checkout: `git clone https://github.com/LuxorLabs/tenki-openclaw && openclaw plugins install ./tenki-openclaw`.)

Then enable it in your OpenClaw config:

```jsonc
{
"plugins": {
"entries": {
"tenki": {
"enabled": true,
"config": {
"image": "ubuntu-24",
"memoryMb": 4096,
},
},
},
},
"agents": {
"defaults": {
"sandbox": {
"mode": "all",
"backend": "tenki",
},
},
},
}
```

The config schema is strict — `authToken`, `baseUrl`, `workspaceId`, `image`, `workspaceRoot`, `idleTimeoutMinutes`, `cpuCores`, `memoryMb`, `diskSizeGb`, and `tags` are the only keys.

## How execution reaches the VM

Shell scripts run over the SDK exec surface as `/bin/sh -c`, with a `set --` prefix carrying positional args and stdin staged as a session file that the script redirects onto fd 0. Interactive PTY sessions are plain `ssh -tt` — but there is no raw host or port to point it at: the plugin listens on a loopback socket and pipes each connection into the SDK's gateway **WebSocket** SSH stream. Tenki's edge gateway accepts only certificate auth, so the plugin keeps a dedicated ed25519 key and mints short-lived per-session user certificates through the SDK, re-minting near expiry.

## Verify

```bash
npm install
node verify.mjs
```

No OpenClaw host needed: [`verify.mjs`](verify.mjs) asserts the published plugin's OpenClaw manifest contract (plugin id `tenki`, strict `configSchema`, extension entry, host version pin), then proves the live path the backend rides on — create a tagged session, find it by tag, exec with the `set --` prefix, round-trip stdin through a staged session file, mint an SSH cert and open the gateway stream, dispose. The backend's own behavior is covered by CI at [LuxorLabs/tenki-openclaw](https://github.com/LuxorLabs/tenki-openclaw).

## Notes

- This is a **community plugin** installed from ClawHub — it does not ship with OpenClaw. (It was proposed upstream in [openclaw/openclaw#111792](https://github.com/openclaw/openclaw/pull/111792), which was closed; the standalone package is the supported path.)
- The plugin pins `@tenkicloud/sandbox` 0.5.2; this example depends on `^0.5.4`, which has the same session surface.
- Bugs go to [LuxorLabs/tenki-openclaw](https://github.com/LuxorLabs/tenki-openclaw/issues), not the OpenClaw tracker.
10 changes: 10 additions & 0 deletions examples/openclaw-tenki/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "openclaw-tenki",
"private": true,
"type": "module",
"description": "Cookbook example — OpenClaw agents with all tool execution in Tenki Firecracker microVMs via @tenkicloud/openclaw.",
"dependencies": {
"@tenkicloud/openclaw": "^0.2.0",
"@tenkicloud/sandbox": "^0.5.4"
}
}
143 changes: 143 additions & 0 deletions examples/openclaw-tenki/verify.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Proves the @tenkicloud/openclaw sandbox backend's contract and the live
* Tenki path it drives. Part 1 asserts the published plugin's OpenClaw
* manifest (id "tenki", strict configSchema, extension entry, host pin).
* Part 2 mirrors the backend's exact SDK calls: tagged find-or-create,
* `/bin/sh -c` with a `set --` positional-args prefix, stdin staged as a
* session file, and cert-based SSH over the gateway WebSocket stream.
* Token/workspace from env (CI) or ~/.config/tenki/config.yaml (local `tenki login`).
* Exits non-zero on any failure.
*/
import { TenkiSandbox, stdoutText } from "@tenkicloud/sandbox";
import { spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";

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") || undefined;
if (!authToken) {
console.error("No token. Set TENKI_AUTH_TOKEN, or run `tenki login`.");
process.exit(1);
}

// Part 1: the plugin contract OpenClaw loads. The dist bundle externalizes
// `openclaw/plugin-sdk/*` (host-provided at runtime), so it only imports
// inside an OpenClaw host — assert the manifest contract instead.
const CONFIG_KEYS = [
"authToken", "baseUrl", "workspaceId", "image", "workspaceRoot",
"idleTimeoutMinutes", "cpuCores", "memoryMb", "diskSizeGb", "tags",
];

const require_ = createRequire(import.meta.url);
const pkgPath = require_.resolve("@tenkicloud/openclaw/package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const manifest = JSON.parse(readFileSync(join(dirname(pkgPath), "openclaw.plugin.json"), "utf8"));

const TAG = "oc-tenki-cookbook-verify";
let client;
let session;
let keyDir;
try {
if (manifest.id !== "tenki") throw new Error(`manifest id is ${JSON.stringify(manifest.id)}`);
if (manifest.configSchema?.additionalProperties !== false) {
throw new Error("configSchema is not strict (additionalProperties !== false)");
}
const props = Object.keys(manifest.configSchema?.properties ?? {});
const missing = CONFIG_KEYS.filter((k) => !props.includes(k));
if (missing.length) throw new Error(`configSchema missing keys: ${missing.join(", ")}`);
if (!Array.isArray(pkg.openclaw?.extensions) || pkg.openclaw.extensions.length === 0) {
throw new Error("package.json openclaw.extensions is empty");
}
if (!pkg.openclaw?.install?.minHostVersion) throw new Error("openclaw.install.minHostVersion missing");
if (!pkg.dependencies?.["@tenkicloud/sandbox"]) throw new Error("@tenkicloud/sandbox dependency missing");

// Part 2: the live session lifecycle the backend rides on, one call at a time.
client = new TenkiSandbox({ authToken });
session = await client.create({
name: TAG,
tags: ["openclaw", TAG],
workspaceId,
cpuCores: 1,
memoryMb: 1024,
allowOutbound: true,
});
await session.waitReady();

// Find-by-tag is how the backend reuses one session per sandbox scope.
const found = await client.list({ tags: [TAG], workspaceId });
if (!found.some((s) => s.id === session.id)) throw new Error(`list({tags:["${TAG}"]}) did not return the session`);

// Shell scripts run as `/bin/sh -c` with a `set --` prefix carrying $1..$n.
const r1 = await session.exec("/bin/sh", { args: ["-c", `set -- 'hello world'\necho "arg:$1"`] });
if (!(r1.exitCode === 0 && stdoutText(r1).trim() === "arg:hello world")) {
throw new Error(`positional args: exit ${r1.exitCode}, stdout ${JSON.stringify(stdoutText(r1))}`);
}

// The SDK exec surface has no stdin stream; the backend stages stdin as a
// session file and redirects fd 0 before the script runs.
const stdinFile = `/home/tenki/.openclaw-stdin-${randomUUID()}`;
await session.writeFile(stdinFile, "stdin staged as a session file\n");
const r2 = await session.exec("/bin/sh", {
args: ["-c", `exec 0<"$OPENCLAW_STDIN_FILE" && rm -f -- "$OPENCLAW_STDIN_FILE"\ncat`],
env: { OPENCLAW_STDIN_FILE: stdinFile },
});
if (!(r2.exitCode === 0 && stdoutText(r2).trim() === "stdin staged as a session file")) {
throw new Error(`stdin staging: exit ${r2.exitCode}, stdout ${JSON.stringify(stdoutText(r2))}`);
}

// Interactive PTY = plain ssh, authenticated with an ed25519 key plus a
// short-lived user cert, over the SDK's gateway WebSocket stream.
keyDir = mkdtempSync(join(tmpdir(), "oc-tenki-verify-"));
const keyPath = join(keyDir, "id_ed25519");
const gen = spawnSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", keyPath]);
if (gen.status !== 0) throw new Error(`ssh-keygen failed: ${gen.stderr}`);
const cert = await client.issueSandboxSSHCert(session.id, readFileSync(`${keyPath}.pub`, "utf8").trim());
if (!cert.sshCert.startsWith("ssh-ed25519-cert")) {
throw new Error(`unexpected cert type: ${cert.sshCert.slice(0, 40)}`);
}

const conn = await session.ssh();
try {
const first = await Promise.race([
conn.read(),
new Promise((resolve) => setTimeout(resolve, 10_000, "timeout")),
]);
if (first === "timeout") {
console.log("note: SSH gateway stream opened but sent no banner within 10s; skipping banner check");
} else {
const banner = new TextDecoder().decode(first ?? new Uint8Array());
if (!banner.includes("SSH-2.0")) throw new Error(`no SSH banner in first chunk: ${JSON.stringify(banner)}`);
}
} finally {
conn.close();
}

console.log(
"✓ openclaw-tenki: plugin contract → live find-by-tag → sh positional args → stdin staging → SSH cert + gateway → dispose",
);
} catch (e) {
console.error("✗ " + (e?.message ?? e));
process.exitCode = 1;
} finally {
if (keyDir) rmSync(keyDir, { recursive: true, force: true });
if (session) {
try {
await session.closeIfOpen();
} catch {
/* self-reaps via idle timeout */
}
}
client?.close();
}
Loading