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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@learnpro/llm": "workspace:*",
"@learnpro/sandbox": "workspace:*",
"@learnpro/scoring": "workspace:*",
"@learnpro/shared": "workspace:*",
"fastify": "^5.2.0"
Expand Down
66 changes: 63 additions & 3 deletions apps/api/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,34 @@
import { describe, it, expect } from "vitest";
import type { SandboxProvider, SandboxRunRequest, SandboxRunResponse } from "@learnpro/sandbox";
import { buildServer } from "./index.js";

class FakeSandbox implements SandboxProvider {
readonly name = "fake-sandbox";
public lastReq: SandboxRunRequest | null = null;

constructor(
private readonly response:
| SandboxRunResponse
| ((r: SandboxRunRequest) => SandboxRunResponse) = {
stdout: "hello\n",
stderr: "",
exit_code: 0,
duration_ms: 12,
killed_by: null,
language: "python",
runtime_version: "3.10.0",
},
) {}

async run(req: SandboxRunRequest): Promise<SandboxRunResponse> {
this.lastReq = req;
return typeof this.response === "function" ? this.response(req) : this.response;
}
}

describe("apps/api", () => {
it("GET /health returns ok payload", async () => {
const app = buildServer();
const app = buildServer({ sandbox: new FakeSandbox() });
const res = await app.inject({ method: "GET", url: "/health" });
expect(res.statusCode).toBe(200);
const body = res.json() as { ok: boolean; service: string };
Expand All @@ -13,7 +38,7 @@ describe("apps/api", () => {
});

it("GET /policies reports the wired policy implementations", async () => {
const app = buildServer();
const app = buildServer({ sandbox: new FakeSandbox() });
const res = await app.inject({ method: "GET", url: "/policies" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
Expand All @@ -26,10 +51,45 @@ describe("apps/api", () => {
});

it("GET /llm reports the wired provider name", async () => {
const app = buildServer();
const app = buildServer({ sandbox: new FakeSandbox() });
const res = await app.inject({ method: "GET", url: "/llm" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ provider: "anthropic" });
await app.close();
});

it("GET /sandbox reports the wired sandbox provider name", async () => {
const app = buildServer({ sandbox: new FakeSandbox() });
const res = await app.inject({ method: "GET", url: "/sandbox" });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ provider: "fake-sandbox" });
await app.close();
});

it("POST /sandbox/run forwards a valid request and returns the run result", async () => {
const sandbox = new FakeSandbox();
const app = buildServer({ sandbox });
const res = await app.inject({
method: "POST",
url: "/sandbox/run",
payload: { language: "python", code: "print('hello')" },
});
expect(res.statusCode).toBe(200);
const body = res.json() as SandboxRunResponse;
expect(body.stdout).toBe("hello\n");
expect(body.exit_code).toBe(0);
expect(sandbox.lastReq?.language).toBe("python");
await app.close();
});

it("POST /sandbox/run rejects invalid input with 400", async () => {
const app = buildServer({ sandbox: new FakeSandbox() });
const res = await app.inject({
method: "POST",
url: "/sandbox/run",
payload: { language: "rust", code: "" },
});
expect(res.statusCode).toBe(400);
await app.close();
});
});
35 changes: 35 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ import {
loadLLMConfigFromEnv,
type LLMProvider,
} from "@learnpro/llm";
import {
buildSandboxProvider,
loadSandboxConfigFromEnv,
SandboxRequestError,
SandboxRunRequestSchema,
type SandboxProvider,
} from "@learnpro/sandbox";

const PORT = Number(process.env["PORT"] ?? 4000);
const HOST = process.env["HOST"] ?? "0.0.0.0";

export interface BuildServerOptions {
policies?: PolicyRegistry;
llm?: LLMProvider;
sandbox?: SandboxProvider;
}

function defaultLLM(): LLMProvider {
Expand Down Expand Up @@ -48,11 +56,17 @@ function defaultLLM(): LLMProvider {
return buildLLMProvider({ config });
}

function defaultSandbox(): SandboxProvider {
const config = loadSandboxConfigFromEnv(process.env);
return buildSandboxProvider({ config });
}

export function buildServer(opts: BuildServerOptions = {}) {
const app = Fastify({ logger: true });
const policies =
opts.policies ?? buildPolicyRegistry({ config: loadPolicyConfigFromEnv(process.env) });
const llm = opts.llm ?? defaultLLM();
const sandbox = opts.sandbox ?? defaultSandbox();

app.get("/health", async () => healthPayload({ service: "api" }));

Expand All @@ -67,6 +81,27 @@ export function buildServer(opts: BuildServerOptions = {}) {
provider: llm.name,
}));

app.get("/sandbox", async () => ({
provider: sandbox.name,
}));

app.post("/sandbox/run", async (req, reply) => {
const parsed = SandboxRunRequestSchema.safeParse(req.body);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid_request", issues: parsed.error.issues });
}
try {
const result = await sandbox.run(parsed.data);
return reply.code(200).send(result);
} catch (err) {
if (err instanceof SandboxRequestError) {
req.log.warn({ err }, "sandbox provider error");
return reply.code(502).send({ error: "sandbox_unavailable", message: err.message });
}
throw err;
}
});

return app;
}

Expand Down
31 changes: 29 additions & 2 deletions packages/sandbox/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# `@learnpro/sandbox`

`SandboxProvider` interface + Piston-on-Docker adapter.
`SandboxProvider` interface + Piston-on-Docker adapter, per [ADR-0002](../../docs/architecture/ADR-0002-sandbox.md).

**Status:** stub. Real Python runner lands in STORY-007, TS runner in STORY-008, hardening verified in STORY-010 per [ADR-0002](../../docs/architecture/ADR-0002-sandbox.md).
## What's here

- `provider.ts` — single-method `SandboxProvider` interface (`run(req) → response`).
- `types.ts` — Zod schemas at the boundary: `SandboxRunRequestSchema`, `SandboxRunResponseSchema`, language and `killed_by` enums, telemetry event.
- `piston.ts` — `PistonSandboxProvider` (depends only on a `PistonTransport` interface — easy to fake in unit tests).
- `piston-http-transport.ts` — real `fetch`-based transport against a self-hosted Piston instance (default `http://localhost:2000`).
- `registry.ts` — `buildSandboxProvider()` factory + `loadSandboxConfigFromEnv()` (`PISTON_URL` → baseUrl override).
- `telemetry.ts` — null + in-memory `SandboxTelemetrySink` implementations.
- `errors.ts` — `SandboxRequestError`, `SandboxLanguageNotSupportedError`.

## Languages (MVP)

- `python` → Piston `python@3.10.0`
- `typescript` → Piston `typescript@5.0.3` (used by STORY-008)

Override per-language versions through `SandboxConfig.languages`.

## Tests

- `piston.test.ts` — unit tests with `FakePistonTransport`. Cover happy path, stdin forwarding, language spec mapping, timeout / OOM / output-limit / signal classification, telemetry, and zod input validation.
- `registry.test.ts` — config defaults, `PISTON_URL` env handling, `LEARNPRO_SANDBOX_CONFIG` JSON parsing.
- `piston.integration.test.ts` — gated on `PISTON_URL`; runs `print('hello')` and a runaway loop against a real Piston (start it via `infra/docker/docker-compose.dev.yaml`).

## What lives elsewhere

- **TS runner specifics**: STORY-008.
- **Hardening verification (no-net, ro rootfs, cgroups, seccomp, non-root)**: STORY-010 — every bullet from the ADR-0002 hardening checklist gets an automated breakout test in `packages/sandbox/test/breakout/`.
- **API wiring**: `apps/api/src/index.ts` exposes `GET /sandbox` (provider name) and `POST /sandbox/run` (zod-validated body → run result).
3 changes: 3 additions & 0 deletions packages/sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.2",
Expand Down
18 changes: 18 additions & 0 deletions packages/sandbox/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export class SandboxRequestError extends Error {
readonly provider: string;
override readonly cause?: unknown;

constructor(message: string, provider: string, cause?: unknown) {
super(message);
this.name = "SandboxRequestError";
this.provider = provider;
if (cause !== undefined) this.cause = cause;
}
}

export class SandboxLanguageNotSupportedError extends Error {
constructor(provider: string, language: string) {
super(`${provider} does not support language "${language}"`);
this.name = "SandboxLanguageNotSupportedError";
}
}
45 changes: 42 additions & 3 deletions packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
export const PACKAGE_NAME = "@learnpro/sandbox";

export interface SandboxProvider {
readonly name: string;
}
export type { SandboxProvider } from "./provider.js";

export {
DEFAULT_PISTON_LANGUAGES,
PistonSandboxProvider,
type PistonExecuteParams,
type PistonExecuteResponse,
type PistonLanguageSpec,
type PistonSandboxProviderOptions,
type PistonTransport,
} from "./piston.js";

export { PistonHttpTransport, type PistonHttpTransportOptions } from "./piston-http-transport.js";

export {
buildSandboxProvider,
loadSandboxConfigFromEnv,
SandboxConfigSchema,
type BuildSandboxOptions,
type SandboxConfig,
} from "./registry.js";

export { InMemorySandboxTelemetrySink, NullSandboxTelemetrySink } from "./telemetry.js";

export { SandboxLanguageNotSupportedError, SandboxRequestError } from "./errors.js";

export {
DEFAULT_MEMORY_LIMIT_MB,
DEFAULT_OUTPUT_LIMIT_BYTES,
DEFAULT_TIME_LIMIT_MS,
SandboxKilledBySchema,
SandboxLanguageSchema,
SandboxRunRequestSchema,
SandboxRunResponseSchema,
SandboxTelemetryEventSchema,
type SandboxKilledBy,
type SandboxLanguage,
type SandboxRunRequest,
type SandboxRunResponse,
type SandboxTelemetryEvent,
type SandboxTelemetrySink,
} from "./types.js";
39 changes: 39 additions & 0 deletions packages/sandbox/src/piston-http-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { PistonExecuteParams, PistonExecuteResponse, PistonTransport } from "./piston.js";

export interface PistonHttpTransportOptions {
baseUrl: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
}

export class PistonHttpTransport implements PistonTransport {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;

constructor(opts: PistonHttpTransportOptions) {
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
this.fetchImpl = opts.fetchImpl ?? fetch;
this.timeoutMs = opts.timeoutMs ?? 30_000;
}

async execute(params: PistonExecuteParams): Promise<PistonExecuteResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const res = await this.fetchImpl(`${this.baseUrl}/api/v2/execute`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(params),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Piston HTTP ${res.status}: ${body || res.statusText}`);
}
return (await res.json()) as PistonExecuteResponse;
} finally {
clearTimeout(timer);
}
}
}
38 changes: 38 additions & 0 deletions packages/sandbox/src/piston.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { PistonSandboxProvider } from "./piston.js";
import { PistonHttpTransport } from "./piston-http-transport.js";

const baseUrl = process.env["PISTON_URL"];
const describeIfPiston = baseUrl ? describe : describe.skip;

describeIfPiston("PistonSandboxProvider (integration — requires PISTON_URL)", () => {
it("runs print('hello') and returns the expected stdout", async () => {
const provider = new PistonSandboxProvider({
transport: new PistonHttpTransport({ baseUrl: baseUrl! }),
});
const res = await provider.run({
language: "python",
code: "print('hello')",
time_limit_ms: 5_000,
memory_limit_mb: 128,
output_limit_bytes: 64 * 1024,
});
expect(res.stdout.trim()).toBe("hello");
expect(res.exit_code).toBe(0);
expect(res.killed_by).toBeNull();
}, 30_000);

it("kills runaway code at the wall-clock timeout", async () => {
const provider = new PistonSandboxProvider({
transport: new PistonHttpTransport({ baseUrl: baseUrl! }),
});
const res = await provider.run({
language: "python",
code: "while True: pass",
time_limit_ms: 1_000,
memory_limit_mb: 128,
output_limit_bytes: 64 * 1024,
});
expect(res.killed_by).toBe("timeout");
}, 30_000);
});
Loading
Loading