Skip to content

Commit 57ff074

Browse files
committed
feat: switch to Docker runtime with built-in compilers for code execution
- Dockerfile now includes Python 3, GCC, G++, OpenJDK 17, Rust - Render runtime changed from 'node' to 'docker' - Sandbox falls back to direct execution with sanitized env (no secrets exposed) - Removed broken Piston API (went whitelist-only Feb 2026) - Command allowlist + deny patterns enforce security - 15s timeout, 100KB output cap - Updated README: PostgreSQL, direct execution docs
1 parent ad78de6 commit 57ff074

4 files changed

Lines changed: 92 additions & 126 deletions

File tree

README.md

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,11 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
126126
└─────────────────┼───────────────────────────────┘
127127
128128
┌─────────────────▼───────────────────────────────┐
129-
│ Express API (Render)
130-
│ ┌────────┐ ┌──────────┐ ┌──────────────────┐ │
131-
│ │ SQLite │ │ Terminal │ │ Docker Sandbox │ │
132-
│ │ DB │ │ WS │ │ (code execution) │ │
133-
│ └────────┘ └──────────┘ └──────────────────┘ │
129+
│ Express API (Render – Docker)
130+
│ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │
131+
│ │ Postgres │ │ Terminal │ │ Code Execution │ │
132+
│ │(Supabase)│ │ WS │ │ (direct + safe)│ │
133+
│ └──────────┘ └──────────┘ └────────────────┘ │
134134
│ ┌──────────────────────────────────────────┐ │
135135
│ │ AI Agent (Gemini / OpenRouter / Ollama) │ │
136136
│ └──────────────────────────────────────────┘ │
@@ -139,17 +139,16 @@ OLLAMA_BASE_URL=http://127.0.0.1:11434
139139

140140
---
141141

142-
## 🐳 Docker Sandbox
142+
## � Code Execution
143143

144-
```bash
145-
./scripts/setup-compilers.ps1
146-
```
144+
Supported languages: **JavaScript, TypeScript, Python, C, C++, Java, Rust**
147145

148-
- 🔒 Network disabled (no internet for user code)
149-
- ⚡ 1 CPU / 512 MB RAM limit
150-
- ⏱️ 30-second execution timeout
151-
- 📁 Per-project workspace volume
152-
- ✅ Command allowlist
146+
- 🔒 Sanitized environment (no access to secrets/env vars)
147+
- ⚡ Strict command allowlist + deny patterns
148+
- ⏱️ 15-second execution timeout
149+
- 📁 Per-project workspace isolation
150+
- 🐳 Docker sandbox available locally (full isolation)
151+
- 🏗️ Direct execution on deploy (compilers in container)
153152

154153
---
155154

@@ -206,9 +205,9 @@ scripts/ → Setup scripts, smoke tests
206205
| Editor | Monaco Editor (VS Code engine) |
207206
| Terminal | xterm.js + WebSocket |
208207
| Backend | Express 4, Node.js 22 |
209-
| Database | SQLite (via node:sqlite) |
208+
| Database | PostgreSQL (Supabase) |
210209
| AI | Google Gemini, OpenRouter, Ollama |
211-
| Sandbox | Docker with resource limits |
210+
| Sandbox | Docker / Direct execution (Python, GCC, JDK, Rust) |
212211
| Animation | Framer Motion |
213212
| Auth | JWT + bcrypt |
214213
| Deploy | Vercel + Render (free tier) |

render.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
services:
22
- type: web
33
name: novaforge-api
4-
runtime: node
4+
runtime: docker
55
region: oregon
66
plan: free
77
rootDir: services/api-node
8-
buildCommand: npm install --include=dev && npm run build
9-
startCommand: node dist/index.js
8+
dockerfilePath: ./Dockerfile
109
envVars:
1110
- key: NODE_ENV
1211
value: production

services/api-node/Dockerfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,30 @@ RUN npx tsc
88

99
FROM node:22-alpine
1010
WORKDIR /app
11+
12+
# Install language runtimes for code execution
13+
RUN apk add --no-cache \
14+
python3 \
15+
py3-pip \
16+
gcc \
17+
g++ \
18+
musl-dev \
19+
openjdk17-jdk \
20+
rust \
21+
cargo \
22+
bash
23+
24+
# Create a restricted user for running user code
25+
RUN adduser -D -s /bin/sh sandbox
26+
1127
COPY package*.json ./
1228
RUN npm install --omit=dev
1329
COPY --from=builder /app/dist ./dist
1430
COPY data ./data
31+
32+
# Ensure workspaces directory exists
33+
RUN mkdir -p /app/workspaces && chown node:node /app/workspaces
34+
1535
EXPOSE 8787
1636
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost:8787/health || exit 1
1737
CMD ["node", "dist/index.js"]

services/api-node/src/sandbox.ts

Lines changed: 55 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { spawn } from "node:child_process";
2-
import { readFile } from "node:fs/promises";
3-
import path from "node:path";
42
import { assertCommandAllowed, workspacePath } from "./security.js";
53

64
const dockerImages: Record<string, string> = {
@@ -31,8 +29,8 @@ export async function runInDocker(workspaceId: string, language: string, command
3129
assertCommandAllowed(command);
3230
const available = await dockerAvailable();
3331
if (!available) {
34-
// Fallback: use Piston API for code execution when Docker is unavailable
35-
return runViaPiston(workspaceId, language, command);
32+
// Fallback: execute directly with a sanitized environment
33+
return runDirectly(workspaceId, language, command);
3634
}
3735

3836
const cwd = workspacePath(workspaceId);
@@ -72,11 +70,63 @@ export async function runInDocker(workspaceId: string, language: string, command
7270
});
7371
}
7472

73+
// ── Direct execution fallback (sanitized env, no secrets) ─────────
74+
function runDirectly(workspaceId: string, _language: string, command: string): Promise<{ ok: boolean; output: string }> {
75+
assertCommandAllowed(command);
76+
const cwd = workspacePath(workspaceId);
77+
78+
// Clean environment: only PATH and language-specific vars, NO secrets
79+
const safeEnv: Record<string, string> = {
80+
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
81+
HOME: "/tmp",
82+
LANG: "C.UTF-8",
83+
TERM: "dumb",
84+
JAVA_HOME: "/usr/lib/jvm/java-17-openjdk",
85+
};
86+
87+
return new Promise<{ ok: boolean; output: string }>((resolve) => {
88+
const child = spawn("sh", ["-c", command], {
89+
cwd,
90+
env: safeEnv,
91+
shell: false,
92+
});
93+
let output = "";
94+
let outputSize = 0;
95+
const MAX_OUTPUT = 100_000; // 100KB max output
96+
97+
const timer = setTimeout(() => {
98+
child.kill("SIGKILL");
99+
output += "\n[TIMEOUT] Process killed after 15s";
100+
}, 15_000);
101+
102+
child.stdout.on("data", (chunk: Buffer) => {
103+
if (outputSize < MAX_OUTPUT) {
104+
output += chunk.toString();
105+
outputSize += chunk.length;
106+
}
107+
});
108+
child.stderr.on("data", (chunk: Buffer) => {
109+
if (outputSize < MAX_OUTPUT) {
110+
output += chunk.toString();
111+
outputSize += chunk.length;
112+
}
113+
});
114+
child.on("exit", (code) => {
115+
clearTimeout(timer);
116+
resolve({ ok: code === 0, output: output.trim() || "(No output)" });
117+
});
118+
child.on("error", (err) => {
119+
clearTimeout(timer);
120+
resolve({ ok: false, output: `Execution error: ${err.message}` });
121+
});
122+
});
123+
}
124+
75125
export async function imageStatus() {
76126
const uniqueImages = [...new Set(Object.values(dockerImages))];
77127
const available = await dockerAvailable();
78128
if (!available) {
79-
return uniqueImages.map((image) => ({ image, present: false, reason: "Docker unavailable (using Piston API)" }));
129+
return uniqueImages.map((image) => ({ image, present: false, reason: "Using direct execution (compilers installed)" }));
80130
}
81131

82132
return Promise.all(
@@ -90,105 +140,3 @@ export async function imageStatus() {
90140
)
91141
);
92142
}
93-
94-
// ── Piston API fallback for environments without Docker ───────────
95-
const PISTON_URL = "https://emkc.org/api/v2/piston/execute";
96-
97-
const pistonLangMap: Record<string, { language: string; version: string }> = {
98-
javascript: { language: "javascript", version: "18.15.0" },
99-
node: { language: "javascript", version: "18.15.0" },
100-
typescript: { language: "typescript", version: "5.0.3" },
101-
python: { language: "python", version: "3.10.0" },
102-
c: { language: "c", version: "10.2.0" },
103-
cpp: { language: "c++", version: "10.2.0" },
104-
"c++": { language: "c++", version: "10.2.0" },
105-
java: { language: "java", version: "15.0.2" },
106-
rust: { language: "rust", version: "1.68.2" },
107-
html: { language: "javascript", version: "18.15.0" },
108-
css: { language: "javascript", version: "18.15.0" },
109-
};
110-
111-
function extractFilename(command: string): string | null {
112-
// Match common patterns: python 'file.py', node 'file.js', gcc 'file.c', etc.
113-
const patterns = [
114-
/python\s+'([^']+)'/,
115-
/python\s+(\S+)/,
116-
/node\s+'([^']+)'/,
117-
/node\s+(\S+)/,
118-
/npx\s+tsx\s+'([^']+)'/,
119-
/npx\s+tsx\s+(\S+)/,
120-
/gcc\s+'([^']+)'/,
121-
/gcc\s+(\S+)/,
122-
/g\+\+\s+'([^']+)'/,
123-
/g\+\+\s+(\S+)/,
124-
/javac\s+'([^']+)'/,
125-
/javac\s+(\S+)/,
126-
/rustc\s+'([^']+)'/,
127-
/rustc\s+(\S+)/,
128-
];
129-
for (const pattern of patterns) {
130-
const match = command.match(pattern);
131-
if (match) return match[1];
132-
}
133-
return null;
134-
}
135-
136-
async function runViaPiston(workspaceId: string, language: string, command: string): Promise<{ ok: boolean; output: string }> {
137-
const mapping = pistonLangMap[language];
138-
if (!mapping) {
139-
return { ok: false, output: `Language "${language}" is not supported for online execution.` };
140-
}
141-
142-
// Extract filename from the command and read the source file
143-
const filename = extractFilename(command);
144-
if (!filename) {
145-
return { ok: false, output: `Could not determine source file from command: ${command}` };
146-
}
147-
148-
const cwd = workspacePath(workspaceId);
149-
const filePath = path.join(cwd, filename);
150-
let sourceCode: string;
151-
try {
152-
sourceCode = await readFile(filePath, "utf-8");
153-
} catch {
154-
return { ok: false, output: `File not found: ${filename}` };
155-
}
156-
157-
try {
158-
const controller = new AbortController();
159-
const timeout = setTimeout(() => controller.abort(), 30_000);
160-
161-
const response = await fetch(PISTON_URL, {
162-
method: "POST",
163-
headers: { "Content-Type": "application/json" },
164-
signal: controller.signal,
165-
body: JSON.stringify({
166-
language: mapping.language,
167-
version: mapping.version,
168-
files: [{ name: filename, content: sourceCode }],
169-
}),
170-
});
171-
clearTimeout(timeout);
172-
173-
if (!response.ok) {
174-
return { ok: false, output: `Code execution service returned ${response.status}. Please try again.` };
175-
}
176-
177-
const data = await response.json() as { run?: { stdout?: string; stderr?: string; code?: number }; message?: string };
178-
if (data.message) {
179-
return { ok: false, output: data.message };
180-
}
181-
const run = data.run;
182-
if (!run) {
183-
return { ok: false, output: "Unexpected response from execution service." };
184-
}
185-
186-
const output = [run.stdout, run.stderr].filter(Boolean).join("\n").trim() || "(No output)";
187-
return { ok: run.code === 0, output };
188-
} catch (err: any) {
189-
if (err.name === "AbortError") {
190-
return { ok: false, output: "[TIMEOUT] Execution timed out after 30s" };
191-
}
192-
return { ok: false, output: `Execution service error: ${err.message}` };
193-
}
194-
}

0 commit comments

Comments
 (0)