Skip to content
Merged
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
152 changes: 152 additions & 0 deletions apps/web/src/backend/api/code-cell-execute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import { executeCodeCellNode } from './code-cell-node-exec';
import {
clampCodeCellTimeout,
runCodeCellOnServer,
validateCodeCellRequest,
} from './code-cell-execute';

/** Value planted in APP_ENCRYPTION_KEY to prove an escaped cell cannot read it. */
const SENTINEL_SECRET = 'sentinel-must-not-leak';

describe('validateCodeCellRequest', () => {
it('accepts a minimal js payload', () => {
const v = validateCodeCellRequest({
body: 'return [];',
kind: 'js',
last: null,
vars: {},
});
expect(v.ok).toBe(true);
if (v.ok) {
expect(v.value.kind).toBe('js');
expect(v.value.timeoutMs).toBeGreaterThan(0);
}
});

it('rejects bad kind / empty body', () => {
expect(validateCodeCellRequest({ body: '', kind: 'js' }).ok).toBe(false);
expect(validateCodeCellRequest({ body: 'return [];', kind: 'python' }).ok).toBe(false);
});

it('rejects shallow or malformed last/vars', () => {
expect(
validateCodeCellRequest({
body: 'return [];',
kind: 'js',
last: { columns: [1], rows: [[1]], rowCount: 1 },
}).ok
).toBe(false);
expect(
validateCodeCellRequest({
body: 'return [];',
kind: 'js',
last: null,
vars: { n: { kind: 'list' } },
}).ok
).toBe(false);
});

it('clamps timeout', () => {
expect(clampCodeCellTimeout(50)).toBe(100);
expect(clampCodeCellTimeout(999_999)).toBe(30_000);
});
});

describe('executeCodeCellNode', () => {
it('runs async + lodash', async () => {
const r = await executeCodeCellNode({
body: `import _ from 'lodash';
const n = await Promise.resolve(3);
return _.map([n], (x) => ({ x }));
`,
last: null,
vars: {},
maxRows: 10,
});
expect(r).toMatchObject({ ok: true, rowCount: 1 });
if (r.ok) expect(r.rows).toEqual([[3]]);
});

it('supports await fetch via mock', async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ hello: 'node' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch;
try {
const r = await executeCodeCellNode({
body: `
const res = await fetch('https://example.test');
const json = await res.json();
return [{ status: res.status, hello: json.hello }];
`,
last: null,
vars: {},
maxRows: 10,
});
expect(r).toMatchObject({ ok: true });
if (r.ok) expect(r.rows).toEqual([[200, 'node']]);
} finally {
globalThis.fetch = realFetch;
}
});
});

describe('runCodeCellOnServer (worker_threads sandbox)', () => {
async function run(
body: string,
kind: 'js' | 'ts',
timeoutMs = 5000
) {
const validated = validateCodeCellRequest({
body,
kind,
last: null,
vars: {},
maxRows: 10,
timeoutMs,
});
expect(validated.ok).toBe(true);
if (!validated.ok) throw new Error(validated.error);
return runCodeCellOnServer(validated.value);
}

it('transpiles TS and returns a grid', async () => {
// Worker cold-start (tsx) is slower under a full parallel vitest run.
const result = await run(`const n: number = 2;\nreturn [{ n: n * 3 }];`, 'ts', 20_000);
if (!result.ok) throw new Error(result.error);
expect(result.rows).toEqual([[6]]);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
}, 30_000);

it('denies an escaped cell the server environment', async () => {
const prev = process.env.APP_ENCRYPTION_KEY;
process.env.APP_ENCRYPTION_KEY = SENTINEL_SECRET;
try {
// The AsyncFunction preamble shadows `process` lexically only — the Function
// constructor compiles in global scope and reaches the real one. The worker
// thread empties its own copy of process.env so the escape yields nothing.
const result = await run(
`const p = (function(){}).constructor('return process')();\n` +
`return [{ envKeys: Object.keys(p.env).length, secret: String(p.env.APP_ENCRYPTION_KEY) }];`,
'js',
20_000
);
if (!result.ok) throw new Error(result.error);
expect(result.rows).toEqual([[0, 'undefined']]);
// Parent env must stay intact (worker gets its own process.env copy).
expect(process.env.APP_ENCRYPTION_KEY).toBe(SENTINEL_SECRET);
} finally {
if (prev === undefined) delete process.env.APP_ENCRYPTION_KEY;
else process.env.APP_ENCRYPTION_KEY = prev;
}
}, 30_000);

it('kills a cell that never yields', async () => {
const result = await run('while (true) {}\nreturn [];', 'js', 1000);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toMatch(/timed out/i);
}, 15_000);
});
207 changes: 207 additions & 0 deletions apps/web/src/backend/api/code-cell-execute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* POST /api/sql/code-cell — run a `-- @node` / `-- @nodets` cell on the
* FoxSchema Node backend (`kind` is `"js"` | `"ts"` on the wire).
*/

import { Worker } from 'node:worker_threads';
import { fileURLToPath } from 'node:url';
import type { BrowserCodeCellKind } from '@foxschema/core';
import { isCodeCellLast, isCodeCellVars } from '@foxschema/core';
import type { CodeCellLast, CodeCellResult, CodeCellVars } from './code-cell-node-exec';
import { clampMaxRows } from './sql-execute';

export const MAX_CODE_CELL_LENGTH = 100_000;
export const DEFAULT_CODE_CELL_TIMEOUT_MS = 10_000;
export const MAX_CODE_CELL_TIMEOUT_MS = 30_000;

/** Wire language for Node cells (fence `node`/`nodets` already mapped client-side). */
export type CodeCellKindLang = BrowserCodeCellKind;

export type CodeCellRequestBody = {
body?: unknown;
kind?: unknown;
last?: unknown;
vars?: unknown;
maxRows?: unknown;
timeoutMs?: unknown;
};

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

export function clampCodeCellTimeout(v: unknown): number {
const n = typeof v === 'number' ? Math.floor(v) : Number.NaN;
if (!Number.isFinite(n)) return DEFAULT_CODE_CELL_TIMEOUT_MS;
return Math.min(Math.max(n, 100), MAX_CODE_CELL_TIMEOUT_MS);
}

export type ValidatedCodeCell = {
body: string;
kind: CodeCellKindLang;
last: CodeCellLast;
vars: CodeCellVars;
maxRows: number;
timeoutMs: number;
};

export function validateCodeCellRequest(
raw: CodeCellRequestBody
): { ok: true; value: ValidatedCodeCell } | { ok: false; error: string } {
if (typeof raw.body !== 'string' || !raw.body.trim()) {
return { ok: false, error: 'body is required' };
}
if (raw.body.length > MAX_CODE_CELL_LENGTH) {
return { ok: false, error: `body must be under ${MAX_CODE_CELL_LENGTH} characters` };
}
const kind: CodeCellKindLang | null =
raw.kind === 'js' || raw.kind === 'ts' ? raw.kind : null;
if (!kind) return { ok: false, error: 'kind must be "js" or "ts"' };
const lastCandidate = raw.last ?? null;
if (!isCodeCellLast(lastCandidate)) {
return { ok: false, error: 'last must be null or { columns, rows, rowCount }' };
}
const varsCandidate = raw.vars ?? {};
if (!isCodeCellVars(varsCandidate)) {
return { ok: false, error: 'vars must be an object of variable shapes' };
}

return {
ok: true,
value: {
body: raw.body,
kind,
last: lastCandidate,
vars: varsCandidate,
maxRows: clampMaxRows(raw.maxRows),
timeoutMs: clampCodeCellTimeout(raw.timeoutMs),
},
};
}

async function transpileTs(body: string): Promise<string> {
const ts = await import('typescript');
const out = ts.transpileModule(body, {
compilerOptions: {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ESNext,
strict: false,
},
reportDiagnostics: true,
});
const errs = (out.diagnostics ?? []).filter((d) => d.category === ts.DiagnosticCategory.Error);
if (errs.length > 0) {
const msg = errs
.map((d) => ts.flattenDiagnosticMessageText(d.messageText, '\n'))
.join('; ');
throw new Error(msg || 'TypeScript transpile failed');
}
return out.outputText;
}

/**
* Cells run ONLY in a worker thread. There is deliberately no in-process
* fallback: the timeout is enforced by `worker.terminate()`, and a busy loop
* (`while (true) {}`) on the main thread cannot be interrupted — it would wedge
* the whole API server. The worker also runs with a scrubbed `process.env`
* (see code-cell-thread.ts), which an in-process run would not. If the thread
* cannot start, the cell fails closed.
*/
function runInWorkerThread(args: {
body: string;
last: CodeCellLast;
vars: CodeCellVars;
maxRows: number;
timeoutMs: number;
}): Promise<CodeCellResult> {
return new Promise((resolve) => {
let settled = false;
let worker: Worker | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;

const settle = (result: CodeCellResult) => {
if (settled) return;
settled = true;
if (timer !== undefined) clearTimeout(timer);
try {
worker?.terminate();
} catch {
/* ignore */
}
resolve(result);
};

const failed = (detail: string) =>
settle({
ok: false,
error: `Code cell sandbox unavailable: ${detail}`,
});

try {
// Always register tsx so .ts worker entry loads under vitest and plain node.
// Deduplicate if the parent already passed the same flag (tsx server).
const execArgv = [...process.execArgv];
if (!execArgv.some((a) => a.includes('tsx'))) {
execArgv.push('--import', 'tsx/esm');
}
worker = new Worker(fileURLToPath(new URL('./code-cell-thread.ts', import.meta.url)), {
workerData: {
body: args.body,
last: args.last,
vars: args.vars,
maxRows: args.maxRows,
},
execArgv,
});
} catch (error: unknown) {
failed(errorMessage(error));
return;
}

timer = setTimeout(() => {
settle({
ok: false,
error: `Code cell timed out after ${args.timeoutMs}ms`,
});
}, args.timeoutMs);

worker.on('message', (msg: CodeCellResult) => settle(msg));
worker.on('error', (error) => {
if (!settled) failed(errorMessage(error));
});
worker.on('exit', (code) => {
// terminate() after success/timeout often exits non-zero — ignore once settled.
if (!settled && code !== 0) failed(`worker exited with code ${code}`);
});
});
}

/**
* Transpile (if needed) and execute a code cell on Node (worker_threads + timeout).
*/
export async function runCodeCellOnServer(
validated: ValidatedCodeCell
): Promise<CodeCellResult & { durationMs: number }> {
const started = Date.now();
let body = validated.body;
try {
if (validated.kind === 'ts') {
body = await transpileTs(body);
}
} catch (error: unknown) {
return {
ok: false,
error: `TypeScript: ${errorMessage(error)}`,
durationMs: Date.now() - started,
};
}

const result = await runInWorkerThread({
body,
last: validated.last,
vars: validated.vars,
maxRows: validated.maxRows,
timeoutMs: validated.timeoutMs,
});
return { ...result, durationMs: Date.now() - started };
}
Loading
Loading