Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,8 @@ npm install -g context-mode

Each `ctx_execute` call spawns an isolated subprocess with its own process boundary. Scripts can't access each other's memory or state. The subprocess runs your code, captures stdout, and only that stdout enters the conversation context. The raw data — log files, API responses, snapshots — never leaves the sandbox.

Host-driven cancellation is supported end to end: when the MCP client aborts a request, the abort signal reaches `ctx_execute`/`ctx_execute_file` and kills the entire spawned process tree (children and grandchildren — via the dedicated process group on Unix, `taskkill /T` on Windows), never a broad name/port sweep. The per-call `.ctx-mode-*` temp directory is removed afterward, including its `ownership.json` sidecar — a metadata-only manifest (nonce, script path/hash, PIDs, language) that deliberately never contains the executed code, command, cwd, or environment.

Twelve language runtimes are available: JavaScript, TypeScript, Python, Shell, Ruby, Go, Rust, PHP, Perl, R, Elixir, and C#. Bun is auto-detected for 3-5x faster JS/TS execution.

Authenticated CLIs work through credential passthrough — `gh`, `aws`, `gcloud`, `kubectl`, `docker` inherit environment variables and config paths without exposing them to the conversation.
Expand Down
86 changes: 81 additions & 5 deletions src/executor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { spawn, execSync, execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { mkdtempSync, writeFileSync, rmSync, existsSync, realpathSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { createHash, randomBytes } from "node:crypto";
import {
detectRuntimes,
buildCommand,
Expand Down Expand Up @@ -110,6 +111,13 @@ const OS_TMPDIR = (() => {
return "/tmp";
})();

/**
* Grace window (ms) after an abort's tree kill before the execution settles
* deterministically — see the abort handler in #spawn. Bounds abort latency
* when `close` is held up by inherited stdio handles (Windows).
*/
const ABORT_SETTLE_GRACE_MS = 1500;

/**
* Pure helper — exported for unit testing. Issue #782.
*
Expand Down Expand Up @@ -222,6 +230,8 @@ interface ExecuteOptions {
* a non-project cwd (e.g. $HOME).
*/
cwd?: string;
/** Request-scoped cancellation. Never cancels other executions. */
signal?: AbortSignal;
}

interface ExecuteFileOptions extends ExecuteOptions {
Expand Down Expand Up @@ -280,11 +290,27 @@ export class PolyglotExecutor {
}

async execute(opts: ExecuteOptions): Promise<ExecResult> {
const { language, code, timeout, background = false, cwd: cwdOverride } = opts;
const { language, code, timeout, background = false, cwd: cwdOverride, signal } = opts;

// Deterministic pre-abort path: never spawn a process for a request that
// is already cancelled. Spawn-then-kill-immediately races on Windows
// (taskkill can miss a just-created process; even on a clean kill `close`
// can stall on inherited pipe handles), so resolve synchronously instead.
// No temp dir created → nothing to clean up, no listener to remove.
if (signal?.aborted) {
return {
stdout: "",
stderr: "Execution aborted before start",
exitCode: 1,
timedOut: false,
};
}

const tmpDir = mkdtempSync(join(OS_TMPDIR, ".ctx-mode-"));

try {
const filePath = this.#writeScript(tmpDir, code, language);
this.#writeOwnershipManifest(tmpDir, filePath, language);
const cmd = buildCommand(this.#runtimes, language, filePath);

// Rust: compile then run
Expand All @@ -304,7 +330,7 @@ export class PolyglotExecutor {
// Issue #45 — `cwdOverride` lets per-call sites (Codex MCP handlers) pin
// cwd without mutating process-wide state.
const cwd = cwdOverride ?? this.#projectRoot;
const result = await this.#spawn(cmd, cwd, tmpDir, timeout, background);
const result = await this.#spawn(cmd, cwd, tmpDir, timeout, background, signal);

// Skip tmpDir cleanup if process was backgrounded — it may still need files
if (!result.backgrounded) {
Expand All @@ -319,14 +345,29 @@ export class PolyglotExecutor {
}

async executeFile(opts: ExecuteFileOptions): Promise<ExecResult> {
const { path: filePath, language, code, timeout } = opts;
const { path: filePath, language, code, timeout, signal } = opts;
const absolutePath = resolve(this.#projectRoot, filePath);
const wrappedCode = this.#wrapWithFileContent(
absolutePath,
language,
code,
);
return this.execute({ language, code: wrappedCode, timeout });
return this.execute({ language, code: wrappedCode, timeout, signal });
}

#writeOwnershipManifest(tmpDir: string, scriptPath: string, language: Language): void {
// Sidecar intentionally excludes code, command, cwd, and environment so it
// remains safe to retain for a future ownership-verified orphan reaper.
writeFileSync(join(tmpDir, "ownership.json"), JSON.stringify({
version: 1,
nonce: randomBytes(16).toString("hex"),
scriptPath: realpathSync(scriptPath),
scriptSha256: createHash("sha256").update(readFileSync(scriptPath)).digest("hex"),
createdAt: new Date().toISOString(),
executorPid: process.pid,
parentPid: process.ppid,
language,
}) + "\n", { encoding: "utf-8", mode: 0o600 });
}

#writeScript(tmpDir: string, code: string, language: Language): string {
Expand Down Expand Up @@ -412,6 +453,7 @@ export class PolyglotExecutor {
sandboxTmpDir: string,
timeout: number | undefined,
background = false,
signal?: AbortSignal,
): Promise<ExecResult> {
return new Promise((res) => {
// Only .cmd/.bat shims need shell on Windows; real executables don't.
Expand Down Expand Up @@ -467,6 +509,36 @@ export class PolyglotExecutor {

let timedOut = false;
let resolved = false;
let abortSettleTimer: NodeJS.Timeout | undefined;
const abort = () => {
// Exact spawned root only: killTree uses its dedicated process group or
// taskkill /T, never a broad name/port sweep.
if (resolved) return;
killTree(proc);
// Deterministic settle: after the tree kill we do NOT wait forever for
// `close`. Descendants that die slowly (or briefly survive) keep
// inherited stdio pipe handles open — on Windows that can delay the
// root's `close` well past the actual kill. Give real output a short
// grace window, then settle with whatever we captured.
abortSettleTimer = setTimeout(() => {
if (resolved) return;
resolved = true;
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
proc.stdout?.removeAllListeners("data");
proc.stderr?.removeAllListeners("data");
try { proc.kill("SIGKILL"); } catch { /* already gone */ }
res({
stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
stderr: Buffer.concat(stderrChunks).toString("utf-8"),
exitCode: 1,
timedOut: false,
});
}, ABORT_SETTLE_GRACE_MS);
abortSettleTimer.unref?.();
};
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
// Issue #406 — if the caller didn't pass a timeout we don't fire one.
// Timeout policy belongs to the MCP host/client (Claude Code, VSCode,
// JetBrains all enforce their own RPC timeouts); imposing a second
Expand Down Expand Up @@ -538,6 +610,8 @@ export class PolyglotExecutor {

proc.on("close", (exitCode) => {
clearTimeout(timer);
if (abortSettleTimer) clearTimeout(abortSettleTimer);
signal?.removeEventListener("abort", abort);
if (resolved) return; // Already resolved by background timeout
const rawStdout = Buffer.concat(stdoutChunks).toString("utf-8");
let rawStderr = Buffer.concat(stderrChunks).toString("utf-8");
Expand All @@ -559,6 +633,8 @@ export class PolyglotExecutor {

proc.on("error", (err) => {
clearTimeout(timer);
if (abortSettleTimer) clearTimeout(abortSettleTimer);
signal?.removeEventListener("abort", abort);
if (resolved) return; // Already resolved by background timeout
res({
stdout: "",
Expand Down
21 changes: 13 additions & 8 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ const originalRegisterTool = server.registerTool.bind(server);
const [name, config, handler] = args as [
string,
Record<string, unknown>,
(toolArgs: Record<string, unknown>) => Promise<unknown> | unknown,
(toolArgs: Record<string, unknown>, extra?: unknown) => Promise<unknown> | unknown,
];
if (suppressMcpToolsForNativePluginHost) {
emitSuppressionDiagnostic();
Expand All @@ -293,15 +293,19 @@ const originalRegisterTool = server.registerTool.bind(server);

function wrapToolHandler(
name: string,
handler: (toolArgs: Record<string, unknown>) => Promise<unknown> | unknown,
): (toolArgs: Record<string, unknown>) => Promise<unknown> {
return async (toolArgs: Record<string, unknown>) => {
handler: (toolArgs: Record<string, unknown>, extra?: unknown) => Promise<unknown> | unknown,
): (toolArgs: Record<string, unknown>, extra?: unknown) => Promise<unknown> {
return async (toolArgs: Record<string, unknown>, extra?: unknown) => {
// #854: mark a tool call in-flight so the bridge-child idle reaper never
// shuts the server down mid-execution during a long ctx_execute/batch that
// emits no further inbound messages. Symmetric end in finally (success+error).
noteRequestStart();
try {
return await handler(toolArgs);
// The MCP SDK invokes registered tool handlers with (args, extra), where
// extra.signal carries request cancellation from the host. Forward it —
// dropping it silently disabled the executor-level abort wiring (#83aefee):
// every handler saw extra === {} so no running execution could be cancelled.
return await handler(toolArgs, extra);
} catch (err) {
const result = storageErrorResult(err);
if (result) {
Expand Down Expand Up @@ -1740,7 +1744,7 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
),
}),
},
async ({ language, code, timeout, background, cwd, intent }) => {
async ({ language, code, timeout, background, cwd, intent }, extra: { signal?: AbortSignal } = {}) => {
// Security: deny-only firewall
if (language === "shell") {
const denied = checkDenyPolicy(code, "execute");
Expand Down Expand Up @@ -1819,7 +1823,7 @@ __cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nse
})(typeof require!=='undefined'?require:null);`;
}
const effTimeout = resolveExecTimeout(timeout);
const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd });
const result = await executor.execute({ language, code: instrumentedCode, timeout: effTimeout, background, cwd, signal: extra.signal });

// Echo the executed source code before stdout so users can audit
// and tooling can block command patterns (Issues #717 + #736).
Expand Down Expand Up @@ -2111,7 +2115,7 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
),
}),
},
async ({ path, language, code, timeout, intent }) => {
async ({ path, language, code, timeout, intent }, extra: { signal?: AbortSignal } = {}) => {
// Security (#852): confine the processed file to the project root so
// ctx_execute_file cannot be used to escape the host's sandbox/permission
// controls. Runs before the deny-glob check — boundary first, then policy.
Expand All @@ -2138,6 +2142,7 @@ EXAMPLE: ctx_execute_file(path: "data.csv", language: "javascript", code: "const
language,
code,
timeout: effTimeout,
signal: extra.signal,
});

// Echo path + executed source code before stdout for audit/debug
Expand Down
Loading