diff --git a/backend/container/e2b.Dockerfile b/backend/container/e2b.Dockerfile new file mode 100644 index 0000000..844b85f --- /dev/null +++ b/backend/container/e2b.Dockerfile @@ -0,0 +1,19 @@ +# e2b template build --name "terminal" + +# Use a Debian-based base image +FROM ubuntu:22.04 + +# Install dependencies and customize sandbox +RUN apt update \ + && apt install -y sudo + +# Install xterm +RUN apt update \ + && apt install -y xterm + +RUN apt update \ + && apt install -y tmux screen + +# Clean up +RUN apt clean \ + && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/backend/container/e2b.toml b/backend/container/e2b.toml new file mode 100644 index 0000000..fbcec6c --- /dev/null +++ b/backend/container/e2b.toml @@ -0,0 +1,14 @@ +# This is a config for E2B sandbox template. +# You can use 'template_id' (ne8xtb57tq5xw9vwhdyc) or 'template_name (terminal) from this config to spawn a sandbox: + +# Python SDK +# from e2b import Sandbox +# sandbox = Sandbox(template='terminal') + +# JS SDK +# import { Sandbox } from 'e2b' +# const sandbox = await Sandbox.create({ template: 'terminal' }) + +dockerfile = "e2b.Dockerfile" +template_name = "terminal" +template_id = "ne8xtb57tq5xw9vwhdyc" diff --git a/backend/server/package-lock.json b/backend/server/package-lock.json index e0262a2..cc4ebea 100644 --- a/backend/server/package-lock.json +++ b/backend/server/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@e2b/code-interpreter": "^0.0.7", "concurrently": "^8.2.2", "cors": "^2.8.5", "dotenv": "^16.4.5", @@ -50,6 +51,39 @@ "node": ">=12" } }, + "node_modules/@e2b/code-interpreter": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@e2b/code-interpreter/-/code-interpreter-0.0.7.tgz", + "integrity": "sha512-e8nAY4zXU2b9nKthqq/pCPlTVD7f01dtzCtvabWmhlx7Wq+AUln14Q1Wf+uRVJXHkwS9BDv2CupdZpUChsjoCA==", + "dependencies": { + "e2b": "^0.16.1", + "isomorphic-ws": "^5.0.0", + "ws": "^8.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@e2b/code-interpreter/node_modules/ws": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -369,6 +403,19 @@ "node": ">=8" } }, + "node_modules/bufferutil": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.8.tgz", + "integrity": "sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -662,6 +709,59 @@ "url": "https://dotenvx.com" } }, + "node_modules/e2b": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-0.16.1.tgz", + "integrity": "sha512-2L1R/REEB+EezD4Q4MmcXXNATjvCYov2lv/69+PY6V95+wl1PZblIMTYAe7USxX6P6sqANxNs+kXqZr6RvXkSw==", + "dependencies": { + "isomorphic-ws": "^5.0.0", + "normalize-path": "^3.0.0", + "openapi-typescript-fetch": "^1.1.3", + "path-browserify": "^1.0.1", + "platform": "^1.3.6", + "ws": "^8.15.1" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "bufferutil": "^4.0.8", + "utf-8-validate": "^6.0.3" + } + }, + "node_modules/e2b/node_modules/utf-8-validate": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.4.tgz", + "integrity": "sha512-xu9GQDeFp+eZ6LnCywXN/zBancWvOpUMzgjLPSjy4BRHSmTelvn2E0DG0o1sTiw5hkCKBHo8rwSKncfRfv2EEQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/e2b/node_modules/ws": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1082,6 +1182,14 @@ "node": ">=0.12.0" } }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -1186,6 +1294,17 @@ "node": ">= 0.6" } }, + "node_modules/node-gyp-build": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", + "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -1265,7 +1384,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -1297,6 +1415,15 @@ "node": ">= 0.8" } }, + "node_modules/openapi-typescript-fetch": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/openapi-typescript-fetch/-/openapi-typescript-fetch-1.1.3.tgz", + "integrity": "sha512-smLZPck4OkKMNExcw8jMgrMOGgVGx2N/s6DbKL2ftNl77g5HfoGpZGFy79RBzU/EkaO0OZpwBnslfdBfh7ZcWg==", + "engines": { + "node": ">= 12.0.0", + "npm": ">= 7.0.0" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1305,6 +1432,11 @@ "node": ">= 0.8" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -1322,6 +1454,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1835,6 +1972,20 @@ "node": ">= 0.8" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "optional": true, + "peer": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/server/package.json b/backend/server/package.json index e0ac9c5..577a870 100644 --- a/backend/server/package.json +++ b/backend/server/package.json @@ -11,6 +11,7 @@ "author": "", "license": "ISC", "dependencies": { + "@e2b/code-interpreter": "^0.0.7", "concurrently": "^8.2.2", "cors": "^2.8.5", "dotenv": "^16.4.5", diff --git a/backend/server/src/index.ts b/backend/server/src/index.ts index 92c45df..b381420 100644 --- a/backend/server/src/index.ts +++ b/backend/server/src/index.ts @@ -18,7 +18,7 @@ import { renameFile, saveFile, } from "./utils"; -import { IDisposable, IPty, spawn } from "node-pty"; +import { Sandbox, Process, ProcessMessage } from "e2b"; import { MAX_BODY_SIZE, createFileRL, @@ -44,7 +44,10 @@ let inactivityTimeout: NodeJS.Timeout | null = null; let isOwnerConnected = false; const terminals: { - [id: string]: { terminal: IPty; onData: IDisposable; onExit: IDisposable }; + [id: string]: Process; +} = {}; +const containers: { + [id: string]: Sandbox; } = {}; const dirName = path.join(__dirname, ".."); @@ -100,6 +103,32 @@ io.use(async (socket, next) => { next(); }); +class LockManager { + private locks: { [key: string]: Promise }; + + constructor() { + this.locks = {}; + } + + async acquireLock(key: string, task: () => Promise): Promise { + if (!this.locks[key]) { + this.locks[key] = new Promise(async (resolve, reject) => { + try { + const result = await task(); + resolve(result); + } catch (error) { + reject(error); + } finally { + delete this.locks[key]; + } + }); + } + return await this.locks[key]; + } +} + +const lockManager = new LockManager(); + io.on("connection", async (socket) => { if (inactivityTimeout) clearTimeout(inactivityTimeout); @@ -118,6 +147,16 @@ io.on("connection", async (socket) => { } } + await lockManager.acquireLock(data.sandboxId, async () => { + if (!containers[data.sandboxId]) { + console.log("Creating container ", data.sandboxId); + containers[data.sandboxId] = await Sandbox.create({ + template: "terminal", + }); + console.log("Created."); + } + }); + const sandboxFiles = await getSandboxFiles(data.sandboxId); sandboxFiles.fileData.forEach((file) => { const filePath = path.join(dirName, file.id); @@ -319,42 +358,55 @@ io.on("connection", async (socket) => { callback(newFiles.files); }); - socket.on("createTerminal", (id: string, callback) => { + function toBackslashNotation(input: string) { + return input + .replace(/\\/g, "\\\\") // Escape backslashes + .replace(/\n/g, "\\n") // Escape newlines + .replace(/\r/g, "\\r") // Escape carriage returns + .replace(/\t/g, "\\t") // Escape tabs + .replace(/"/g, '\\"') // Escape double quotes + .replace(/'/g, "\\'") // Escape single quotes + .replace(/\f/g, "\\f") // Escape form feeds + .replace(/\b/g, "\\b") // Escape backspaces + .replace(/\v/g, "\\v") // Escape vertical tabs + .replace(/\0/g, "\\0") // Escape null characters + .replace(/\a/g, "\\a") // Escape alert (bell) + .replace(/\e/g, "\\e"); // Escape escape + } + + socket.on("createTerminal", async (id: string, callback) => { if (terminals[id] || Object.keys(terminals).length >= 4) { return; } - const pty = spawn(os.platform() === "win32" ? "cmd.exe" : "bash", [], { - name: "xterm", - cols: 100, - cwd: path.join(dirName, "projects", data.sandboxId), - }); - - const onData = pty.onData((data) => { + const onData = (data: ProcessMessage) => { + console.log("process", toBackslashNotation(data.toString())); io.emit("terminalResponse", { id, - data, + data: data.toString(), }); - }); - - const onExit = pty.onExit((code) => console.log("exit :(", code)); - - pty.write("export PS1='\\u > '\r"); - pty.write("clear\r"); - - terminals[id] = { - terminal: pty, - onData, - onExit, }; + await lockManager.acquireLock(data.sandboxId, async () => { + console.log("Creating terminal", id); + terminals[id] = await containers[data.sandboxId].process.start({ + cmd: 'TERM=xterm script -c "screen" /dev/null', // xterm vt100 + onStdout: onData, + onStderr: onData, + onExit: (code) => console.log("exit :(", code), + }); + terminals[id].sendStdin("export PS1='\\u > '\r\n"); + terminals[id].sendStdin("clear\r\n"); + console.log("Created terminal", id); + }); + callback(); }); socket.on("resizeTerminal", (dimensions: { cols: number; rows: number }) => { - Object.values(terminals).forEach((t) => { + /*Object.values(terminals).forEach((t) => { t.terminal.resize(dimensions.cols, dimensions.rows); - }); + });*/ }); socket.on("terminalData", (id: string, data: string) => { @@ -363,19 +415,19 @@ io.on("connection", async (socket) => { } try { - terminals[id].terminal.write(data); + console.log(`Writing ${toBackslashNotation(data)} to ${id}`); + terminals[id].sendStdin(data); } catch (e) { console.log("Error writing to terminal", e); } }); - socket.on("closeTerminal", (id: string, callback) => { + socket.on("closeTerminal", async (id: string, callback) => { if (!terminals[id]) { return; } - terminals[id].onData.dispose(); - terminals[id].onExit.dispose(); + await terminals[id].kill(); delete terminals[id]; callback(); @@ -429,12 +481,20 @@ io.on("connection", async (socket) => { socket.on("disconnect", async () => { if (data.isOwner) { Object.entries(terminals).forEach((t) => { - const { terminal, onData, onExit } = t[1]; - onData.dispose(); - onExit.dispose(); + const terminal = t[1]; + terminal.kill(); delete terminals[t[0]]; }); + await lockManager.acquireLock(data.sandboxId, async () => { + if (containers[data.sandboxId]) { + console.log("Closing container", data.sandboxId); + await containers[data.sandboxId].close(); + delete containers[data.sandboxId]; + console.log("Closed"); + } + }); + socket.broadcast.emit( "disableAccess", "The sandbox owner has disconnected."