Files
vhaudiquet c1059c23e0 build: the cloud frontend stands alone
The Durable Object, the sandbox daemon, and their deploy assets
leave the rhema workspace for a repository of their own. The DO
was already a deployment artifact (publish = false, worker feature
wasm-only), and rhema-sandbox-server's only consumer was the image
beside it — both are product, not framework. The harness and the
seam stay behind: rhema-cloud-agent and the sandbox family join
the rhema repository's publish list, and this tree consumes them
from crates.io at 0.2.6, the same arrangement the claw keeps its
framework crates under.

The sandboxd image build carries the fix its extraction needed:
the old Dockerfile still staged the claw's workspace (claw and
tools manifests, integrations globs, the vendored crossterm) and
missed rhema-provider-registry's manifest entirely. The manifest
stage is now this workspace's two members, fetched from the
registry.

Not yet buildable: the rhema 0.2.6 batch (rhema-sandbox,
-local, -http, rhema-cloud-agent) must publish first — then one
cargo check generates Cargo.lock and CI goes green.
2026-11-13 12:05:30 +01:00

189 lines
6.4 KiB
JavaScript

// The sandbox shim: a small JS Durable Object that turns plain JSON
// over HTTP into Durable Object Container calls. The container runtime
// (`ctx.container`) is a JS-only API — workers-rs cannot reach it — so
// the Rust agent DO speaks this surface instead of fighting JS RPC.
//
// One shim instance per session, named after the session like the
// agent DO, so the container's lifetime hangs off the same name the
// checkpoints do. `SANDBOX_TOKEN` guards the HTTP surface: the agent
// DO sends it in `x-rhema-token`, and nothing else should answer here.
//
// Contract (all routes JSON):
// POST /exec { command, cwd?, timeout_secs }
// -> { exit_code, stdout, stderr, timed_out }
// POST /put { path, content_base64 } -> { error: null | message }
// POST /get { path } -> { content_base64 } | { error: message }
// POST /seed { git } | { tarball_base64 } -> { error: null | message }
//
// Output streams are tail-capped before they cross the wire, so a
// runaway command cannot flood the DO; the tool applies its display
// caps again on top.
const MAX_STREAM_BYTES = 50 * 1024;
// The put/get/seed routes ride command arguments, which are bounded;
// keep the payloads comfortably inside that.
const MAX_FILE_BYTES = 512 * 1024;
const IMAGE = "cloudflare/debian-trixie";
// Keep the container warm for a quarter hour after the last command —
// the idle sleep the pricing caveat is about (see README.md).
const INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000;
/// Shell-quotes one argument, single-quote style.
function shquote(value) {
return `'${String(value).replaceAll(`'`, `'\\''`)}'`;
}
function cap(text) {
return text.length <= MAX_STREAM_BYTES ? text : text.slice(text.length - MAX_STREAM_BYTES);
}
function clampTimeout(secs) {
const n = Number(secs);
if (!Number.isFinite(n)) return 120;
return Math.min(Math.max(Math.round(n), 1), 600);
}
export class SandboxShim {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
}
async ensureRunning() {
const container = this.ctx.container;
if (!container) {
throw new Error("the container binding is not configured");
}
if (!container.running) {
container.start({
image: IMAGE,
entrypoint: ["sleep", "infinity"],
enableInternet: true,
});
await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
}
}
/// Runs one command with a wall-clock budget; `timeout` kills the
/// command and every process it started.
async exec(command, timeoutSecs) {
await this.ensureRunning();
const argv = [
"timeout",
"--kill-after=5",
String(timeoutSecs),
"bash",
"-c",
command,
];
const process = await this.ctx.container.exec(argv);
const output = await process.output();
const decoder = new TextDecoder();
return {
exit_code: output.exitCode,
stdout: cap(decoder.decode(output.stdout)),
stderr: cap(decoder.decode(output.stderr)),
// Only GNU timeout's own 124 means a budget kill. 137 is an
// external SIGKILL (OOM most often): reporting it as a timeout
// teaches the model to widen the budget and re-run the same
// command, which dies the same way — so it surfaces as a plain
// failure instead.
timed_out: output.exitCode === 124,
};
}
async fetch(request) {
const token = this.env.SANDBOX_TOKEN;
if (token && request.headers.get("x-rhema-token") !== token) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const route = new URL(request.url).pathname;
let body = {};
try {
body = await request.json();
} catch {
// Routes that need a body fail on their own below.
}
try {
switch (route) {
case "/healthz":
return Response.json({ ok: true });
case "/exec": {
const cwd =
typeof body.cwd === "string" && body.cwd
? `cd ${shquote(body.cwd)} && `
: "";
const result = await this.exec(
`${cwd}{ ${body.command ?? ":"} ; }`,
clampTimeout(body.timeout_secs),
);
return Response.json(result);
}
case "/put": {
const path = String(body.path ?? "");
const b64 = String(body.content_base64 ?? "");
if (b64.length > (MAX_FILE_BYTES * 4) / 3 + 4) {
return Response.json({ error: "file too large" }, { status: 413 });
}
const result = await this.exec(
`mkdir -p "$(dirname ${shquote(path)})" && ` +
`printf %s ${shquote(b64)} | base64 -d > ${shquote(path)}`,
60,
);
if (result.exit_code !== 0) {
return Response.json(
{ error: result.stderr || "write failed" },
{ status: 500 },
);
}
return Response.json({ error: null });
}
case "/get": {
const path = String(body.path ?? "");
const result = await this.exec(
`base64 ${shquote(path)} 2>/dev/null | head -c ${MAX_FILE_BYTES * 2}`,
60,
);
if (result.exit_code !== 0) {
return Response.json({ error: "no such file" }, { status: 404 });
}
return Response.json({
content_base64: result.stdout.replace(/\s+/g, ""),
error: null,
});
}
case "/seed": {
if (body.git) {
const result = await this.exec(
`git clone ${shquote(String(body.git))} .`,
300,
);
if (result.exit_code !== 0) {
return Response.json(
{ error: result.stderr || "clone failed" },
{ status: 500 },
);
}
} else if (body.tarball_base64) {
const result = await this.exec(
`printf %s ${shquote(String(body.tarball_base64))} | base64 -d | tar -xzf -`,
300,
);
if (result.exit_code !== 0) {
return Response.json(
{ error: result.stderr || "unpack failed" },
{ status: 500 },
);
}
}
return Response.json({ error: null });
}
default:
return Response.json({ error: "not found" }, { status: 404 });
}
} catch (err) {
return Response.json({ error: String(err) }, { status: 500 });
}
}
}