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
4 changes: 2 additions & 2 deletions src/mcp/tools/runner-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class RunnerToolRegistry extends ToolRegistry {
"run_local_tests",
{
description:
"Runs the user's code locally in an isolated subprocess, captures stdout / stderr / exit code, and updates the session's lastLocalRunPassed flag. Use this in the inner loop instead of submit_solution — it costs no LeetCode submission and turns around in seconds. The agent is responsible for including test invocations (e.g. `print(Solution().twoSum([2,7,11,15], 9))`) in the code passed in. Phase 4a ships python3; go and java land in Phase 4b/4c.",
"Runs the user's code locally in an isolated subprocess, captures stdout / stderr / exit code, and updates the session's lastLocalRunPassed flag. Use this in the inner loop instead of submit_solution — it costs no LeetCode submission and turns around in seconds. The agent is responsible for including test invocations (e.g. `print(Solution().twoSum([2,7,11,15], 9))`) in the code passed in. Currently runnable: python3 and go; java lands in Phase 4c.",
inputSchema: {
titleSlug: z
.string()
Expand All @@ -78,7 +78,7 @@ export class RunnerToolRegistry extends ToolRegistry {
.max(60_000)
.optional()
.describe(
"Optional wall-clock budget in milliseconds. Defaults to 5000."
"Optional wall-clock budget in milliseconds. Defaults are language-specific."
)
}
},
Expand Down
5 changes: 3 additions & 2 deletions src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ export const SUPPORTED_LANGUAGES: readonly RunnerLanguage[] = [

/**
* The languages this build of the runner has *implemented*. Phase 4a
* ships `python3` only. Phase 4b/4c grow this list.
* shipped `python3`; Phase 4b adds `go`; Phase 4c adds `java`.
*
* Kept distinct from `SUPPORTED_LANGUAGES` so the wire-level
* `RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE` error has a single source of
* truth: anything in `SUPPORTED_LANGUAGES` but not in this list is a
* "coming soon" language.
*/
export const IMPLEMENTED_LANGUAGES: readonly RunnerLanguage[] = [
"python3"
"python3",
"go"
] as const;
21 changes: 13 additions & 8 deletions src/runner/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,11 @@ export async function detectSandbox(): Promise<DetectedSandbox> {
export async function wrapWithSandbox(
cmd: string,
args: string[],
cwdAllowed: string
cwdAllowed: string,
writablePaths: string[] = []
): Promise<{ cmd: string; args: string[]; kind: SandboxKind }> {
const detected = await detectSandbox();
const allowedWritePaths = [cwdAllowed, ...writablePaths];
if (detected.kind === "bwrap") {
return {
cmd: "bwrap",
Expand All @@ -125,9 +127,7 @@ export async function wrapWithSandbox(
"/",
"--tmpfs",
"/tmp",
"--bind",
cwdAllowed,
cwdAllowed,
...allowedWritePaths.flatMap((path) => ["--bind", path, path]),
"--proc",
"/proc",
"--dev",
Expand All @@ -149,7 +149,7 @@ export async function wrapWithSandbox(
"--noprofile",
"--net=none",
"--private-tmp",
`--whitelist=${cwdAllowed}`,
...allowedWritePaths.map((path) => `--whitelist=${path}`),
"--",
cmd,
...args
Expand All @@ -159,14 +159,19 @@ export async function wrapWithSandbox(
}
if (detected.kind === "sandbox-exec") {
// Minimal sandbox-exec profile — deny by default, allow process
// primitives + reads everywhere + writes only under cwdAllowed.
const writableSubpath = escapeSandboxProfileString(cwdAllowed);
// primitives + reads everywhere + writes only under allowed paths.
const writableSubpaths = allowedWritePaths
.map(
(path) =>
`(allow file-write* (subpath "${escapeSandboxProfileString(path)}"))`
)
.join("\n");
const profile = `(version 1)
(deny default)
(allow process-fork)
(allow process-exec)
(allow file-read*)
(allow file-write* (subpath "${writableSubpath}"))
${writableSubpaths}
(allow file-write* (regex #"^/dev/null$"))
(allow file-write* (regex #"^/dev/dtracehelper$"))
(allow sysctl-read)
Expand Down
95 changes: 74 additions & 21 deletions src/runner/subprocess-runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Plain-subprocess `LocalRunner` implementation.
*
* Per-language registry (currently `python3`) describes how to:
* Per-language registry (currently `python3` and `go`) describes how to:
* - probe whether the runtime is available on PATH
* - spawn the runtime against a source file written to the run's
* temp dir
Expand All @@ -10,10 +10,10 @@
* results are cached for the lifetime of the process.
*
* Safety nets every run gets, even with no OS sandbox:
* - per-process wall-clock timeout (default 5_000 ms; configurable
* per `RunInput`)
* - clean env (just PATH / HOME / LANG forwarded — secrets in the
* user's shell never leak in)
* - per-process wall-clock timeout (language-specific default;
* configurable per `RunInput`)
* - clean env (PATH / HOME / LANG plus language-specific cache dirs —
* secrets in the user's shell never leak in)
* - cwd is a freshly-mkdtemp'd directory under the OS tmp; it is
* removed after the run regardless of outcome
* - stdout/stderr captured with a 1 MB ceiling; runaway output gets
Expand All @@ -24,7 +24,7 @@ import {
spawn,
type ChildProcess
} from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
Expand All @@ -50,21 +50,65 @@ const execFile = promisify(execFileCb);

const MAX_OUTPUT_BYTES = 1_000_000; // 1 MB per stream
const DEFAULT_TIMEOUT_MS = 5_000;
const GO_DEFAULT_TIMEOUT_MS = 20_000;
const TRUNCATION_MARKER = "\n[...output truncated at 1 MB...]";

interface LanguageSpec {
/** File extension (without dot) used for the temp source file. */
extension: string;
/** `[binary, args]` to probe — exit code 0 means available. */
probe: { cmd: string; args: string[] };
defaultTimeoutMs?: number;
prepareRuntime?(): Promise<RuntimeLayout>;
/**
* Build the spawn args given the path of the source file we wrote
* for this run. Compiled languages (Go, Java) will hook in extra
* compile steps via subclassing later.
* for this run.
*/
buildArgs(sourcePath: string): { cmd: string; args: string[] };
}

interface RuntimeLayout {
env: Record<string, string>;
writablePaths: string[];
}

interface GoRuntimePaths {
root: string;
buildCache: string;
moduleCache: string;
}

let goRuntimePathsPromise: Promise<GoRuntimePaths> | undefined;

async function getGoRuntimePaths(): Promise<GoRuntimePaths> {
if (!goRuntimePathsPromise) {
const attempt = (async () => {
const root = await mkdtemp(join(tmpdir(), "leetcode-mcp-go-"));
const buildCache = join(root, "go-build");
const moduleCache = join(root, "gomod");
await mkdir(buildCache, { recursive: true });
await mkdir(moduleCache, { recursive: true });
return { root, buildCache, moduleCache };
})();
goRuntimePathsPromise = attempt.catch((error) => {
goRuntimePathsPromise = undefined;
throw error;
});
}
return goRuntimePathsPromise;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function prepareGoRuntime(): Promise<RuntimeLayout> {
const paths = await getGoRuntimePaths();
return {
env: {
GOCACHE: paths.buildCache,
GOMODCACHE: paths.moduleCache
},
writablePaths: [paths.root]
};
}

const LANGUAGES: Record<RunnerLanguage, LanguageSpec> = {
python3: {
extension: "py",
Expand All @@ -74,19 +118,19 @@ const LANGUAGES: Record<RunnerLanguage, LanguageSpec> = {
args: [sourcePath]
})
},
// Phase 4b/4c stubs — present in the registry so the type system
// requires they stay in sync with `RunnerLanguage`. The runner
// refuses to use these until we actually wire harnesses.
go: {
extension: "go",
probe: { cmd: "go", args: ["version"] },
buildArgs: () => {
throw new LeetCodeError(
ErrorCode.RUNNER_NOT_IMPLEMENTED_FOR_LANGUAGE,
"Go runner ships in Phase 4b"
);
}
defaultTimeoutMs: GO_DEFAULT_TIMEOUT_MS,
prepareRuntime: prepareGoRuntime,
buildArgs: (sourcePath) => ({
cmd: "go",
args: ["run", sourcePath]
})
},
// Phase 4c stub — present in the registry so the type system
// requires it stays in sync with `RunnerLanguage`. The runner
// refuses to use it until we actually wire the harness.
java: {
extension: "java",
probe: { cmd: "java", args: ["-version"] },
Expand Down Expand Up @@ -223,7 +267,12 @@ export class SubprocessRunner implements LocalRunner {
}

const spec = LANGUAGES[input.language];
const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutMs =
input.timeoutMs ?? spec.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
const runtimeLayout = (await spec.prepareRuntime?.()) ?? {
env: {},
writablePaths: []
};
const workDir = await mkdtemp(join(tmpdir(), "leetcode-mcp-run-"));
const sourcePath = join(workDir, `solution.${spec.extension}`);

Expand All @@ -233,15 +282,17 @@ export class SubprocessRunner implements LocalRunner {
const wrapped = await wrapWithSandbox(
baseArgs.cmd,
baseArgs.args,
workDir
workDir,
runtimeLayout.writablePaths
);

return await this.spawnAndCapture({
cmd: wrapped.cmd,
args: wrapped.args,
cwd: workDir,
timeoutMs,
sandbox: wrapped.kind
sandbox: wrapped.kind,
env: runtimeLayout.env
});
} finally {
await rm(workDir, { recursive: true, force: true }).catch(
Expand All @@ -261,6 +312,7 @@ export class SubprocessRunner implements LocalRunner {
cwd: string;
timeoutMs: number;
sandbox: SandboxKind;
env: Record<string, string>;
}): Promise<RunResult> {
return new Promise((resolve) => {
const start = performance.now();
Expand All @@ -269,7 +321,8 @@ export class SubprocessRunner implements LocalRunner {
env: {
PATH: process.env.PATH ?? "",
HOME: options.cwd,
LANG: process.env.LANG ?? "C.UTF-8"
LANG: process.env.LANG ?? "C.UTF-8",
...options.env
},
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"]
Expand Down
7 changes: 4 additions & 3 deletions src/types/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ export interface RunInput {
*/
code: string;
/**
* Wall-clock budget in milliseconds. Defaults to 5_000 if omitted.
* The runner kills the subprocess when this elapses and returns
* `timedOut: true` with whatever partial output was captured.
* Wall-clock budget in milliseconds. If omitted, the runner chooses
* a language-specific default. The runner kills the subprocess when
* this elapses and returns `timedOut: true` with whatever partial
* output was captured.
*/
timeoutMs?: number;
}
Expand Down
Loading
Loading