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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ COMPUTER_IMAGE=openmuse-computer:local
# COMPUTER_DEPLOYMENT_ID is required for e2b-desktop: sandboxes are matched across the E2B team
# COMPUTER_E2B_TEMPLATE=desktop # optional custom template built from "desktop"
# E2B_DOMAIN= # only when not using the default E2B cluster
# Or on a BoxCompute VM (graphical desktop with internet access; see docs/COMPUTER.md):
# COMPUTER_PROVIDER=boxcompute
# BOXCOMPUTE_API_KEY= # server-only; required for boxcompute, never sent into the VM
# COMPUTER_DEPLOYMENT_ID is required for boxcompute too: VMs are matched across the account
# BOXCOMPUTE_WORKSPACE_ID= # optional; default: a workspace named OpenMuse, created if missing
# BOXCOMPUTE_API_URL=https://api.boxcompute.ai # optional

# Optional Jev interaction choices: off (default), sample scripted decisions, or live SDK.
JEV_MODE=off
Expand Down
4 changes: 4 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ Docker shares its host kernel and does not provide a full VM or a hostile-tenant

With `COMPUTER_PROVIDER=e2b-desktop`, each owner's computer is a dedicated E2B Desktop VM instead of a Docker container, and the paragraphs above about Docker networking, the read-only root filesystem, capabilities and limits do not apply. Outbound internet access is enabled, the in-VM `user` account has passwordless sudo, and `/workspace` is stored with E2B. Internet access is an egress path that OpenMuse does not review: approval gating covers only the app's own email and calendar tools, so a prompt-injected agent could send data from the computer or submit forms in its browser. The live desktop stream is reachable from the internet and protected only by the VNC password in its URL (8 effective characters); treat that URL as a bearer secret. All listening VM ports are publicly reachable at `https://<port>-<sandboxId>.e2b.app` without application authentication; public traffic is enabled to allow the direct desktop iframe. The sandbox ID appears in the stream hostname and is not an access control. For example, `python3 -m http.server 8000` in `/workspace` exposes workspace files to the internet. Do not leave servers running or put sensitive files in an unauthenticated service. The iframe is sandboxed to block forms, popups and top-level navigation, but VM-controlled content can still display misleading prompts. The E2B API key stays on the API host and is never passed into the VM or to the client. A unique `COMPUTER_DEPLOYMENT_ID` is required, because sandboxes are matched by metadata across the whole E2B team. See [the E2B desktop section](docs/COMPUTER.md#run-the-computer-on-an-e2b-desktop-optional).

### BoxCompute provider (opt-in)

With `COMPUTER_PROVIDER=boxcompute`, each owner's computer is a dedicated BoxCompute VM instead of a Docker container, and the paragraphs above about Docker networking, the read-only root filesystem, capabilities and limits do not apply. Outbound internet access is enabled, commands run as root in the VM, and `/workspace` is stored on the VM's disk at BoxCompute. As with E2B, internet access is an egress path OpenMuse does not review. The desktop stream is a BoxCompute viewer link: a random hostname that serves a noVNC page and is a bearer secret giving full keyboard and mouse control until it expires or the API key is revoked. The page is embedded with the same iframe sandbox as the E2B stream. No other port in the VM is reachable from outside. The BoxCompute API key stays on the API and task worker, and is never sent into the VM or to the client. `COMPUTER_DEPLOYMENT_ID` is required because VMs are matched by metadata across the whole account. See [docs/COMPUTER.md](docs/COMPUTER.md#run-the-computer-on-a-boxcompute-vm-optional).

## External actions

A proposal is bound to the account, reviewed content, and applicable provider version. The server requires a recorded approval before dispatching a send or calendar change. An uncertain network outcome is retained for reconciliation. Cancellation stops later task steps; a provider request already in flight may still finish.
Expand Down
297 changes: 297 additions & 0 deletions apps/computer/boxcompute-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import assert from "node:assert/strict";
import { randomBytes, randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { PDFDocument } from "pdf-lib";
import { createApp } from "../server/src/app.ts";
import { computerIdentity } from "../server/src/computer.ts";
import type { Config } from "../server/src/config.ts";
import { createStore } from "../server/src/db.ts";

// Explicit opt-in script against real BoxCompute: a unique deployment, and every VM it
// creates is deleted afterwards. A BoxCompute console key has every scope it uses:
// sandbox:read, create, execute and delete, plus workspace:create unless
// BOXCOMPUTE_WORKSPACE_ID is set.
// Run: BOXCOMPUTE_API_KEY=... pnpm test:computer:boxcompute
const apiKey = process.env.BOXCOMPUTE_API_KEY?.trim();
const apiUrl = (process.env.BOXCOMPUTE_API_URL?.trim() || "https://api.boxcompute.ai").replace(
/\/+$/,
"",
);
// Listing, deleting and a read-only probe, all safe to repeat, so a connection
// dropped while BoxCompute deploys is retried rather than leaving a VM behind.
const boxcompute = async (path: string, init: RequestInit = {}) => {
for (let attempt = 1; ; attempt++) {
try {
const response = await fetch(`${apiUrl}/api/v2${path}`, {
...init,
headers: { authorization: `Bearer ${apiKey}`, ...(init.headers as Record<string, string>) },
});
if (response.status < 502 || attempt === 5) return response;
} catch (error) {
if (!(error instanceof TypeError) || attempt === 5) throw error;
}
await new Promise((resolve) => setTimeout(resolve, 2000 * attempt));
}
};

test("real BoxCompute VM runs commands, opens Chrome, streams, acts, persists files and stops active work", {
timeout: 900000,
skip: !apiKey && "set BOXCOMPUTE_API_KEY to run the BoxCompute smoke test",
}, async () => {
const directory = await mkdtemp(join(tmpdir(), "openmuse-boxcompute-smoke-"));
const db = await createStore();
const config: Config = {
mode: "sample",
port: 8787,
host: "127.0.0.1",
publicUrl: "http://localhost:8787",
dataDir: directory,
agentBackend: "sample",
intelligenceApiKey: "test-project-key-never-sent",
googleRedirectUri: "http://localhost/callback",
allowedOrigins: [],
computerEnabled: true,
computerProvider: "boxcompute",
boxcomputeApiKey: apiKey,
boxcomputeApiUrl: apiUrl,
boxcomputeWorkspaceId: process.env.BOXCOMPUTE_WORKSPACE_ID?.trim() || undefined,
computerDeploymentId: `smoke-${randomUUID()}`,
};
const server = await createApp(db, config);
const owner = "local-user",
labels = computerIdentity(config, owner).labels;
const vmIds = async () => {
const query = Object.entries(labels).map(
([key, value]) => `metadata=${encodeURIComponent(`${key}=${value}`)}`,
);
const response = await boxcompute(`/sandboxes?${query.join("&")}`);
assert.ok(response.ok, `list: ${response.status}`);
return ((await response.json()) as { sandboxes: { id: string }[] }).sandboxes.map(
(vm) => vm.id,
);
};
let failure: unknown;
try {
const session = await server.auth.session();
const headers = {
Authorization: `Bearer ${session.token}`,
"Content-Type": "application/json",
};
const call = async (path: string, body?: unknown) => {
const response = await server.app.request(`/api/computer${path}`, {
method: body === undefined ? "GET" : "POST",
headers,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const result = await response.json();
assert.ok(response.ok, `${path}: ${JSON.stringify(result)}`);
return result;
};
const post = (path: string, body: unknown = {}) => call(path, body);
// A desktop action, then the PNG screenshot it kept for the chat card.
const act = async (action: Record<string, unknown>) => {
const result = await post("/desktop/actions", action);
assert.equal(result.receipt.status, "succeeded", JSON.stringify(result));
assert.deepEqual([result.width, result.height], [1280, 800]);
const response = await server.app.request("/api/computer/desktop/screenshot", { headers });
assert.equal(response.headers.get("content-type"), "image/png");
const bytes = new Uint8Array(await response.arrayBuffer());
assert.deepEqual([...bytes.subarray(0, 4)], [0x89, 0x50, 0x4e, 0x47]);
// An empty sway desktop compresses to a few KB; Chrome pages are larger.
assert.ok(bytes.length > 1000 && bytes.length < 8 * 1024 * 1024, String(bytes.length));
return bytes;
};
const creating = Date.now();
const started = await post("/start");
console.log(`created and started the VM and its desktop in ${Date.now() - creating} ms`);
assert.equal(started.status, "running");
assert.equal(started.provider, "boxcompute");
assert.equal(started.network, "enabled");
assert.equal(started.desktop, true);
assert.equal((await vmIds()).length, 1);
const command = await post("/commands", {
command:
"id -u; pwd; python3 --version; git --version; printf 'persisted from bash\\n' > receipt.txt",
});
assert.equal(command.status, "succeeded", JSON.stringify(command));
assert.match(command.stdout, /^0\n\/workspace\nPython 3\./);
await post("/files/mkdir", { path: "/workspace/notes" });
await post("/files/write", {
path: "/workspace/notes/hello.txt",
text: "Hello from the API ✓",
});
assert.equal(
(await post("/files/read", { path: "/workspace/notes/hello.txt" })).text,
"Hello from the API ✓",
);
const missing = await post("/commands", { command: "pwd", cwd: "/workspace/missing" });
assert.equal(missing.status, "failed");
const stdout = await post("/commands", { command: "python3 -c 'print(\"x\" * 300000)'" });
assert.equal(stdout.status, "succeeded");
assert.equal(stdout.truncated, true);
assert.ok(Buffer.byteLength(stdout.stdout) <= 128 * 1024);
await post("/commands", { command: "ln -s /etc /workspace/escape" });
const escaped = await server.app.request("/api/computer/files/read", {
method: "POST",
headers,
body: JSON.stringify({ path: "/workspace/escape/passwd" }),
});
assert.equal(escaped.status, 422);
// Typed input reaches a focused page: a local page posts every keystroke back
// to a tiny server, which writes it to a file in /workspace. The input fills the
// page (Chrome for Testing shows a banner above it) and the page is sent as HTML,
// which Chrome does not sniff from a bare <input>.
await post("/files/write", {
path: "/workspace/typing.py",
text: [
"import http.server, pathlib",
'PAGE = b\'<!doctype html><input autofocus style="position:fixed;inset:0;width:100%;height:100%;font-size:40px" oninput="fetch(`/k?`+encodeURIComponent(this.value))">\'',
"class H(http.server.BaseHTTPRequestHandler):",
" def do_GET(self):",
" if self.path.startswith('/k?'):",
" from urllib.parse import unquote",
" pathlib.Path('/workspace/typed.txt').write_text(unquote(self.path[3:]))",
" self.send_response(200); self.send_header('Content-Type', 'text/html'); self.end_headers(); self.wfile.write(PAGE)",
" def log_message(self, *a): pass",
"http.server.HTTPServer(('127.0.0.1', 8765), H).serve_forever()",
].join("\n"),
});
await post("/commands", { command: "python3 /workspace/typing.py >/dev/null 2>&1 &" });
// A backgrounded GUI app must not hold the command open, and must keep running.
const before = Date.now();
const gui = await post("/commands", {
command: "chrome http://127.0.0.1:8765 >/dev/null 2>&1 &",
});
assert.equal(gui.status, "succeeded", JSON.stringify(gui));
assert.ok(Date.now() - before < 15000, "the GUI app held the command open");
await new Promise((resolve) => setTimeout(resolve, 6000));
assert.equal(
// [c] keeps pgrep -f from matching the wrapper's own command line.
(await post("/commands", { command: "pgrep -f '[c]hrome-for-testing' >/dev/null" })).status,
"succeeded",
);
const typeIntoPage = async (text: string) => {
await act({ action: "left_click", coordinate: [640, 480] });
await act({ action: "key", text: "ctrl+a BackSpace" });
await act({ action: "type", text });
const typed = await post("/files/read", { path: "/workspace/typed.txt" });
assert.equal(typed.text, text, "typed input did not reach the page");
};
// Input and screenshots on the freshly created VM.
await act({ action: "screenshot" });
await typeIntoPage("typed fresh 123");
const desktop = await call("/desktop");
assert.match(desktop.url, /^https:\/\/[0-9a-f]{32}\./);
assert.ok(!desktop.url.includes(apiKey ?? "-"));
assert.equal(
(await call("/desktop")).url,
desktop.url,
"a second request returns the same link",
);
const page = await fetch(desktop.url);
assert.equal(page.status, 200);
assert.match(page.headers.get("content-security-policy") ?? "", /frame-ancestors \*/);
assert.equal((await fetch(new URL("/viewer.js", desktop.url))).status, 200);
// A runaway writer is stopped before its temporary output fills the disk.
const runaway = await post("/commands", { command: "yes" });
assert.match(runaway.stderr, /Output exceeded the limit/);
assert.equal(runaway.truncated, true);
const pdf = await PDFDocument.create();
pdf.addPage();
// Close to the 10 MB import limit: larger than one BoxCompute file request.
await pdf.attach(randomBytes(9.5 * 1024 * 1024), "noise.bin", {
mimeType: "application/octet-stream",
});
const original = await server.files.import(owner, "smoke.pdf", await pdf.save(), "Smoke test");
const importing = Date.now();
await post("/files/import", { fileId: original.id, path: "/workspace/source.pdf" });
console.log(`imported ${(await pdf.save()).length} byte PDF in ${Date.now() - importing} ms`);
await post("/commands", { command: "cp source.pdf output.pdf" });
const exported = await post("/files/export", { path: "/workspace/output.pdf" });
assert.deepEqual(
await server.files.bytes(owner, exported.id),
await server.files.bytes(owner, original.id),
);
assert.equal(
(await post("/commands", { command: "ls -a /workspace | grep -c openmuse-" })).stdout.trim(),
"0",
);
// The inner 30 second timeout reports exit 124 and leaves the computer running.
const slow = await post("/commands", { command: "sleep 40" });
assert.equal(slow.status, "timed_out");
assert.equal(slow.exitCode, 124);
assert.equal((await call("")).status, "running");
assert.equal((await post("/stop")).status, "stopped");
const resuming = Date.now();
assert.equal((await post("/start")).status, "running");
console.log(`resumed the VM and its desktop in ${Date.now() - resuming} ms`);
assert.equal(
(await post("/files/read", { path: "/workspace/receipt.txt" })).text,
"persisted from bash\n",
);
// Stop ends processes: the old browser and server are gone, the desktop is back.
assert.equal(
(await post("/commands", { command: "pgrep -f '[c]hrome-for-testing'" })).status,
"failed",
);
await act({ action: "screenshot" });
await post("/commands", {
command:
"python3 /workspace/typing.py >/dev/null 2>&1 & chrome http://127.0.0.1:8765 >/dev/null 2>&1 & sleep 6",
});
await act({ action: "scroll", coordinate: [640, 400], scroll_direction: "down" });
await typeIntoPage("typed resumed");
assert.equal((await fetch((await call("/desktop")).url)).status, 200);
const pending = server.computer.execute(owner, {
command: "printf ready > /workspace/.smoke-running; sleep 25",
});
// Probe through BoxCompute directly, as the E2B test does through its SDK: the
// app's own file calls would wait on the lease the running command holds.
const [vm] = await vmIds();
const deadline = Date.now() + 20000;
while (true) {
const probe = await boxcompute(`/sandboxes/${vm}/execute`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ argv: ["cat", "/workspace/.smoke-running"], timeoutSeconds: 10 }),
})
.then((response) => (response.ok ? response.json() : undefined))
.catch(() => undefined);
if (probe?.result?.stdout === "ready") break;
assert.ok(Date.now() < deadline, "command process failed to begin");
await new Promise((resolve) => setTimeout(resolve, 500));
}
await server.computer.stop(owner);
assert.equal((await pending).status, "interrupted");
assert.equal((await server.computer.snapshot(owner)).status, "stopped");
// A stopped computer refuses commands and desktop actions instead of resuming.
const refused = await server.app.request("/api/computer/desktop/actions", {
method: "POST",
headers,
body: JSON.stringify({ action: "screenshot" }),
});
assert.equal(refused.status, 409);
assert.equal((await server.computer.snapshot(owner)).status, "stopped");
} catch (error) {
failure = error;
throw error;
} finally {
// Never let a cleanup error hide the failure above.
const deleted = await vmIds()
.then((ids) =>
Promise.all(
ids.map(async (id) => (await boxcompute(`/sandboxes/${id}`, { method: "DELETE" })).ok),
),
)
.catch((error: unknown) => {
console.error("smoke VM cleanup failed; delete the deployment's VMs by hand", error);
return [false];
});
await db.close();
await rm(directory, { recursive: true, force: true });
if (!failure) assert.ok(deleted.every(Boolean), "smoke VM cleanup failed");
}
});
4 changes: 1 addition & 3 deletions apps/mobile/src/computer-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,7 @@ export function LinuxWorkspace({
<View style={{ display: tab === "Files" ? "flex" : "none" }}>
<ComputerFiles running={!!running} active={tab === "Files"} />
</View>
{snapshot.provider === "e2b-desktop" && tab === "Desktop" && (
<ComputerDesktop running={!!running} />
)}
{snapshot.desktop && tab === "Desktop" && <ComputerDesktop running={!!running} />}
</>
)}
</View>
Expand Down
3 changes: 1 addition & 2 deletions apps/mobile/src/computer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ export function ComputerSheet() {
const [desktop, setDesktop] = useState<boolean>();
const onSnapshot = useCallback((snapshot: ComputerSnapshot) => {
// A transient provider error keeps the last known state instead of closing the tab.
if (snapshot.status !== "error")
setDesktop(snapshot.provider === "e2b-desktop" && snapshot.status === "running");
if (snapshot.status !== "error") setDesktop(snapshot.desktop && snapshot.status === "running");
}, []);
// A remembered Desktop tab falls back to Terminal while the desktop is off; the draft
// keeps "Desktop", so the tab returns by itself once the computer runs again.
Expand Down
Loading