mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
10e2850d12
* fix(web): re-apply hero pattern + make Insight user-count dynamic Two regressions snuck back in when the /context-saving rename agent copied from the next branch (which never had the hero pattern fix). Re-applying on main: - .cm-master + .cm-wordmark: inline-flex → flex (so the hero-tag pill never shares a baseline-aligned row with the wordmark) - .hero-tag: margin-bottom → margin-top + align-self:flex-start (sits below the wordmark on its own line) - Insight wordmark italic: 1.18em → 1em, margin-left .12em → .14em - HTML order: <h1> wordmark first, <p class="hero-tag"> after (matches master/insight/context-saving) Plus making the Insight landing's "287,000+ developers" references dynamic (.js-user-count + the GitHub stats.json fetch shim), so the number matches the OSS plugin's source-of-truth count instead of drifting like the screenshots showed (290k+ on /, 287k+ here). * feat(og): bake live user-count from stats.json into rendered banners OG PNGs are static, so the 287,000+ number drifted away from the landing page (which already reads stats.json live). Solution: - Mark every user-count occurrence in master-og.html and context-saving-og.html with class js-user-count - render-og.mjs now fetches the same stats.json before opening the browser, then runs a one-shot DOM swap on every .js-user-count element before the screenshot Result: each render bakes the freshest count in, no manual update needed. Today's render shows 290,000+ across all three banners. * perf(web): smooth cursor blink on context-saving hero + topnav The hero green cursor was driving compositor work harder than it needed to: steps(2) + a 24px box-shadow meant the GPU repainted the full glow on every tick, which user reported as a low-FPS feel. - animation timing: 1.06s steps(2) → 1.4s ease-in-out (smooth pulse between opacity 1 and 0.28, terminal-feeling without hard flicker) - box-shadow blur: 24px → 18px and alpha .45 → .40 (lighter recompose) - add will-change:opacity + translateZ(0) so the cursor lives on its own composited layer (no repaint of surrounding text) - topnav cursor: same easing pass, renamed keyframe to nav-blink so the hero and nav don't share a single declaration name - prefers-reduced-motion now resolves to a static 0.85 opacity cursor instead of a stuck-on solid block * ci: update install stats * ci: update install stats * ci: update install stats * ci: update install stats * ci: update install stats * ci: update install stats * ci: update install stats * Fix PowerShell Unicode path handling * ci: update install stats * ci: update install stats * ci: update install stats * test: expect UTF-8 BOM in buildPowerShellScriptContent output The duplicate suite at tests/executor.test.ts still asserted the script started with the bare [Console] prelude. After #839 prepends a UTF-8 BOM (U+FEFF) so Windows PowerShell 5.1 detects the script as UTF-8, that startsWith() assertion failed on every platform (macos + ubuntu CI). Update the assertion to expect the BOM, mirroring the new test in tests/core/executor.test.ts and preserving the BOM behavior #839 adds. --------- Co-authored-by: Mert Koseoglu <bm.ksglu@gmail.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2085 lines
74 KiB
TypeScript
2085 lines
74 KiB
TypeScript
import { describe, test, expect, afterAll } from "vitest";
|
||
import { strict as assert } from "node:assert";
|
||
import { existsSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
import {
|
||
PolyglotExecutor,
|
||
buildScriptFilename,
|
||
buildShellScriptContent,
|
||
buildPowerShellScriptContent,
|
||
buildSpawnOptions,
|
||
} from "../src/executor.js";
|
||
import {
|
||
detectRuntimes,
|
||
buildCommand,
|
||
getRuntimeSummary,
|
||
type RuntimeMap,
|
||
} from "../src/runtime.js";
|
||
|
||
const runtimes = detectRuntimes();
|
||
const executor = new PolyglotExecutor({ runtimes });
|
||
|
||
function findWindowsGitBash(): string | null {
|
||
if (process.platform !== "win32") return null;
|
||
const candidates = [
|
||
"C:\\Program Files\\Git\\usr\\bin\\bash.exe",
|
||
"C:\\Program Files\\Git\\bin\\bash.exe",
|
||
"C:\\Program Files (x86)\\Git\\usr\\bin\\bash.exe",
|
||
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
||
];
|
||
return candidates.find(p => existsSync(p)) ?? null;
|
||
}
|
||
|
||
describe("Runtime Detection", () => {
|
||
test("detects JavaScript runtime (bun or node)", async () => {
|
||
const isBun = runtimes.javascript.endsWith("bun");
|
||
const isAbsoluteNode = runtimes.javascript.startsWith("/") || runtimes.javascript.includes("\\");
|
||
assert.ok(
|
||
isBun || isAbsoluteNode,
|
||
`Expected bun path or absolute node path, got: ${runtimes.javascript}`,
|
||
);
|
||
});
|
||
|
||
test("detects JavaScript runtime (bun or absolute node path)", async () => {
|
||
// runtimes.javascript is either a bun path/command or process.execPath —
|
||
// never the bare string "node", since snap/wrapper envs need the real binary.
|
||
const isBun = runtimes.javascript.endsWith("bun");
|
||
const isAbsoluteNode = runtimes.javascript.startsWith("/") || runtimes.javascript.includes("\\");
|
||
assert.ok(
|
||
isBun || isAbsoluteNode,
|
||
`Expected bun path or absolute node path, got: ${runtimes.javascript}`,
|
||
);
|
||
});
|
||
|
||
test("buildCommand: javascript uses executable path, not bare 'node'", async () => {
|
||
const cmd = buildCommand(runtimes, "javascript", "/tmp/test.js");
|
||
assert.notEqual(cmd[0], "node", "Should not use bare 'node' — use process.execPath or full bun path");
|
||
assert.equal(cmd[cmd.length - 1], "/tmp/test.js");
|
||
});
|
||
|
||
test("buildCommand: javascript with bun-path runtime uses 'run' subcommand", async () => {
|
||
const bunRuntimes: RuntimeMap = { ...runtimes, javascript: "/home/user/.bun/bin/bun" };
|
||
const cmd = buildCommand(bunRuntimes, "javascript", "/tmp/test.js");
|
||
assert.equal(cmd[0], "/home/user/.bun/bin/bun");
|
||
assert.equal(cmd[1], "run");
|
||
assert.equal(cmd[2], "/tmp/test.js");
|
||
});
|
||
|
||
test("buildCommand: javascript with Windows bun.exe runtime uses 'run' subcommand", async () => {
|
||
const bunRuntimes: RuntimeMap = { ...runtimes, javascript: "C:\\Users\\me\\.bun\\bin\\bun.exe" };
|
||
const cmd = buildCommand(bunRuntimes, "javascript", "C:\\tmp\\test.js");
|
||
assert.equal(cmd[0], "C:\\Users\\me\\.bun\\bin\\bun.exe");
|
||
assert.equal(cmd[1], "run");
|
||
assert.equal(cmd[2], "C:\\tmp\\test.js");
|
||
});
|
||
|
||
test("buildCommand: typescript with Windows bun.exe runtime uses 'run' subcommand", async () => {
|
||
const bunRuntimes: RuntimeMap = { ...runtimes, typescript: "C:\\Users\\me\\.bun\\bin\\bun.exe" };
|
||
const cmd = buildCommand(bunRuntimes, "typescript", "C:\\tmp\\test.ts");
|
||
assert.equal(cmd[0], "C:\\Users\\me\\.bun\\bin\\bun.exe");
|
||
assert.equal(cmd[1], "run");
|
||
assert.equal(cmd[2], "C:\\tmp\\test.ts");
|
||
});
|
||
|
||
test("detects Shell runtime (non-empty string)", async () => {
|
||
assert.ok(
|
||
typeof runtimes.shell === "string" && runtimes.shell.length > 0,
|
||
`Got: ${runtimes.shell}`,
|
||
);
|
||
});
|
||
|
||
if (process.platform === "win32") {
|
||
const gitBash = findWindowsGitBash();
|
||
// #791 is specifically Git Bash + native git path conversion; fallback
|
||
// shells are covered by the runtime detection test above.
|
||
const gitBashTest = gitBash ? test : test.skip;
|
||
|
||
test("Windows: shell is Git Bash or fallback, never WSL bash", async () => {
|
||
const shell = runtimes.shell.toLowerCase();
|
||
assert.ok(
|
||
!shell.includes("system32") && !shell.includes("windowsapps"),
|
||
`Shell should not be WSL bash, got: ${runtimes.shell}`,
|
||
);
|
||
});
|
||
|
||
test("Windows: shell execute works with non-ASCII (Chinese) project path", async () => {
|
||
const { mkdirSync } = await import("node:fs");
|
||
const { rm } = await import("node:fs/promises");
|
||
const { tmpdir } = await import("node:os");
|
||
const chineseDir = join(tmpdir(), "测试目录");
|
||
try { mkdirSync(chineseDir, { recursive: true }); } catch {}
|
||
const chineseExecutor = new PolyglotExecutor({ runtimes, projectRoot: chineseDir });
|
||
const r = await chineseExecutor.execute({ language: "shell", code: 'echo "chinese path ok"' });
|
||
assert.equal(r.exitCode, 0, `Failed with stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes("chinese path ok"), `Got: ${r.stdout}`);
|
||
try { await rm(chineseDir, { recursive: true, force: true }); } catch {}
|
||
});
|
||
|
||
gitBashTest("Windows: Git Bash sees native git repos created under mktemp dirs (#791)", async () => {
|
||
const gitBashRuntimes: RuntimeMap = { ...runtimes, shell: gitBash! };
|
||
const gitBashExecutor = new PolyglotExecutor({ runtimes: gitBashRuntimes });
|
||
const previousNoPathConv = process.env.MSYS_NO_PATHCONV;
|
||
const previousArgConvExcl = process.env.MSYS2_ARG_CONV_EXCL;
|
||
process.env.MSYS_NO_PATHCONV = "1";
|
||
process.env.MSYS2_ARG_CONV_EXCL = "*";
|
||
try {
|
||
const r = await gitBashExecutor.execute({
|
||
language: "shell",
|
||
timeout: 20_000,
|
||
code: [
|
||
"set -e",
|
||
"T=$(mktemp -d)",
|
||
"git init --bare --quiet \"$T/origin.git\"",
|
||
"test -d \"$T/origin.git\"",
|
||
"git clone --quiet \"$T/origin.git\" \"$T/clone\"",
|
||
"test -d \"$T/clone/.git\"",
|
||
"echo git bash tmp path ok",
|
||
].join("\n"),
|
||
});
|
||
assert.equal(r.exitCode, 0, `stdout:\n${r.stdout}\nstderr:\n${r.stderr}`);
|
||
assert.ok(r.stdout.includes("git bash tmp path ok"), `Got: ${r.stdout}`);
|
||
} finally {
|
||
if (previousNoPathConv === undefined) delete process.env.MSYS_NO_PATHCONV;
|
||
else process.env.MSYS_NO_PATHCONV = previousNoPathConv;
|
||
if (previousArgConvExcl === undefined) delete process.env.MSYS2_ARG_CONV_EXCL;
|
||
else process.env.MSYS2_ARG_CONV_EXCL = previousArgConvExcl;
|
||
}
|
||
});
|
||
|
||
}
|
||
|
||
test("detects TypeScript runtime", async () => {
|
||
assert.ok(runtimes.typescript !== null, "No TS runtime found");
|
||
});
|
||
|
||
test("detects Python runtime", async () => {
|
||
assert.ok(runtimes.python !== null, "No Python runtime found");
|
||
});
|
||
|
||
test("buildCommand: correct JS command structure", async () => {
|
||
const cmd = buildCommand(runtimes, "javascript", "/tmp/test.js");
|
||
assert.ok(cmd.length >= 2);
|
||
assert.ok(cmd[cmd.length - 1] === "/tmp/test.js");
|
||
});
|
||
|
||
test("buildCommand: throws for unavailable runtime", async () => {
|
||
const noRuntimes: RuntimeMap = {
|
||
javascript: "node",
|
||
typescript: null,
|
||
python: null,
|
||
shell: "sh",
|
||
ruby: null,
|
||
go: null,
|
||
rust: null,
|
||
php: null,
|
||
perl: null,
|
||
r: null,
|
||
elixir: null,
|
||
csharp: null,
|
||
};
|
||
assert.throws(
|
||
() => buildCommand(noRuntimes, "typescript", "/tmp/t.ts"),
|
||
/No TypeScript runtime/,
|
||
);
|
||
assert.throws(
|
||
() => buildCommand(noRuntimes, "python", "/tmp/t.py"),
|
||
/No Python runtime/,
|
||
);
|
||
assert.throws(
|
||
() => buildCommand(noRuntimes, "ruby", "/tmp/t.rb"),
|
||
/Ruby not available/,
|
||
);
|
||
assert.throws(
|
||
() => buildCommand(noRuntimes, "elixir", "/tmp/t.exs"),
|
||
/Elixir not available/,
|
||
);
|
||
assert.throws(
|
||
() => buildCommand(noRuntimes, "csharp", "/tmp/t.csx"),
|
||
/C# not available/,
|
||
);
|
||
});
|
||
|
||
test("buildShellScriptContent restores inherited PATH on Unix", () => {
|
||
const script = buildShellScriptContent("echo ok", "/parent/bin:/usr/bin", "linux");
|
||
assert.equal(script, "export PATH='/parent/bin:/usr/bin'\necho ok");
|
||
});
|
||
|
||
test("buildShellScriptContent escapes single quotes in inherited PATH", () => {
|
||
const script = buildShellScriptContent("echo ok", "/parent/it'works/bin", "darwin");
|
||
assert.equal(script, "export PATH='/parent/it'\\''works/bin'\necho ok");
|
||
});
|
||
|
||
test("buildShellScriptContent leaves Windows shell scripts unchanged", () => {
|
||
const script = buildShellScriptContent("echo ok", "C:\\parent\\bin", "win32");
|
||
assert.equal(script, "echo ok");
|
||
});
|
||
|
||
test("buildPowerShellScriptContent prefixes UTF-8 encoding setup", () => {
|
||
const script = buildPowerShellScriptContent('Write-Output "ok"');
|
||
// Windows PowerShell 5.1 only reliably detects a script as UTF-8 when the
|
||
// file opens with a BOM; without it the body is read as the ANSI code page.
|
||
assert.equal(script.charCodeAt(0), 0xfeff);
|
||
assert.ok(script.startsWith("[Console]::InputEncoding = [System.Text.UTF8Encoding]::new()"));
|
||
assert.ok(script.includes("$OutputEncoding = [System.Text.UTF8Encoding]::new()"));
|
||
assert.ok(script.endsWith('Write-Output "ok"'));
|
||
});
|
||
});
|
||
|
||
describe("JavaScript Execution", () => {
|
||
test("JS: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'console.log("hello from js");',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from js"));
|
||
assert.equal(r.timedOut, false);
|
||
});
|
||
|
||
test("JS: variables, math, template literals", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: "const x = 42; const y = 58; console.log(`sum: ${x + y}`);",
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 100"));
|
||
});
|
||
|
||
test("JS: JSON parse + transform", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: `
|
||
const data = [
|
||
{ name: "Alice", age: 30 },
|
||
{ name: "Bob", age: 25 },
|
||
{ name: "Charlie", age: 35 }
|
||
];
|
||
const avg = data.reduce((s, d) => s + d.age, 0) / data.length;
|
||
console.log(JSON.stringify({ count: data.length, avgAge: avg.toFixed(1) }));
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
const output = JSON.parse(r.stdout.trim());
|
||
assert.equal(output.count, 3);
|
||
assert.equal(output.avgAge, "30.0");
|
||
});
|
||
|
||
test("JS: async/await + setTimeout", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: `
|
||
async function work() {
|
||
return new Promise(resolve => setTimeout(() => resolve("async done"), 50));
|
||
}
|
||
work().then(r => console.log(r));
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("async done"));
|
||
});
|
||
|
||
test("JS: require node:os module", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'const os = require("os"); console.log("platform:", os.platform());',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("platform:"));
|
||
});
|
||
|
||
test("JS: Array.from + map/filter/reduce chain", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: `
|
||
const nums = Array.from({length: 100}, (_, i) => i + 1);
|
||
const evenSum = nums.filter(n => n % 2 === 0).reduce((a, b) => a + b, 0);
|
||
console.log("even sum:", evenSum);
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("even sum: 2550"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.typescript)("TypeScript Execution", () => {
|
||
test("TS: hello world with type annotation", async () => {
|
||
const r = await executor.execute({
|
||
language: "typescript",
|
||
code: 'const msg: string = "hello from ts"; console.log(msg);',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from ts"));
|
||
});
|
||
|
||
test("TS: interface + generics", async () => {
|
||
const r = await executor.execute({
|
||
language: "typescript",
|
||
code: `
|
||
interface Item<T> { id: number; value: T; }
|
||
const items: Item<string>[] = [
|
||
{ id: 1, value: "apple" },
|
||
{ id: 2, value: "banana" },
|
||
];
|
||
function first<T>(arr: T[]): T | undefined { return arr[0]; }
|
||
console.log(first(items)?.value);
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("apple"));
|
||
});
|
||
|
||
test("TS: enum + switch", async () => {
|
||
const r = await executor.execute({
|
||
language: "typescript",
|
||
code: `
|
||
enum Color { Red = "red", Blue = "blue", Green = "green" }
|
||
function describe(c: Color): string {
|
||
switch (c) {
|
||
case Color.Red: return "warm";
|
||
case Color.Blue: return "cool";
|
||
case Color.Green: return "natural";
|
||
}
|
||
}
|
||
console.log(describe(Color.Blue));
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("cool"));
|
||
});
|
||
|
||
test("TS: async + Promise.all", async () => {
|
||
const r = await executor.execute({
|
||
language: "typescript",
|
||
code: `
|
||
async function fetchNum(n: number): Promise<number> {
|
||
return new Promise(resolve => setTimeout(() => resolve(n * 2), 10));
|
||
}
|
||
Promise.all([1, 2, 3].map(fetchNum)).then(results => {
|
||
console.log("doubled:", results.join(", "));
|
||
});
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("doubled: 2, 4, 6"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.python)("Python Execution", () => {
|
||
test("Python: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: 'print("hello from python")',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from python"));
|
||
});
|
||
|
||
test("Python: list comprehension + math", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: `
|
||
nums = [i**2 for i in range(10)]
|
||
print(f"squares: {nums}")
|
||
print(f"sum: {sum(nums)}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 285"));
|
||
});
|
||
|
||
test("Python: dict + json", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: `
|
||
import json
|
||
data = {"users": [{"name": "Alice"}, {"name": "Bob"}]}
|
||
print(json.dumps({"count": len(data["users"])}))
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
const out = JSON.parse(r.stdout.trim());
|
||
assert.equal(out.count, 2);
|
||
});
|
||
|
||
test("Python: csv with io.StringIO", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: `
|
||
import io, csv
|
||
data = "name,age\\nAlice,30\\nBob,25\\nCharlie,35"
|
||
reader = csv.DictReader(io.StringIO(data))
|
||
rows = list(reader)
|
||
print(f"rows: {len(rows)}, names: {[r['name'] for r in rows]}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("rows: 3"));
|
||
});
|
||
|
||
test("Python: regex extraction", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: `
|
||
import re
|
||
text = "Error: 404 at /api/users, Error: 500 at /api/data, OK: 200"
|
||
errors = re.findall(r'Error: (\\d+) at (\\S+)', text)
|
||
print(f"Found {len(errors)} errors: {errors}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("Found 2 errors"));
|
||
});
|
||
|
||
test("Python: collections.Counter", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: `
|
||
from collections import Counter
|
||
words = "the cat sat on the mat the cat".split()
|
||
c = Counter(words)
|
||
print(f"most common: {c.most_common(2)}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("most common:"));
|
||
assert.ok(r.stdout.includes("the"));
|
||
});
|
||
});
|
||
|
||
describe("Shell Execution", () => {
|
||
test("Shell: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "hello from shell"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from shell"));
|
||
});
|
||
|
||
test("Shell: pipes + sort", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'printf "banana\\napple\\ncherry" | sort',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
const lines = r.stdout.trim().split("\n");
|
||
assert.equal(lines[0], "apple");
|
||
assert.equal(lines[1], "banana");
|
||
assert.equal(lines[2], "cherry");
|
||
});
|
||
|
||
test("Shell: arithmetic + variables", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'X=10\nY=20\necho "sum: $((X + Y))"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 30"));
|
||
});
|
||
|
||
test.runIf(process.platform !== "win32")(
|
||
"shell restores inherited PATH after startup clobbers it",
|
||
async () => {
|
||
const originalPath = process.env.PATH;
|
||
const testDir = join(tmpdir(), `ctx-path-${process.pid}-${Date.now()}`);
|
||
const binDir = join(testDir, "bin");
|
||
const fakeBin = join(binDir, "ctx-path-probe");
|
||
const fakeShell = join(testDir, "sh");
|
||
|
||
try {
|
||
mkdirSync(binDir, { recursive: true });
|
||
writeFileSync(fakeBin, "#!/bin/sh\nprintf 'probe-ok\\n'\n", { mode: 0o755 });
|
||
writeFileSync(
|
||
fakeShell,
|
||
"#!/bin/sh\nPATH=/usr/bin:/bin\nexport PATH\nexec /bin/sh \"$@\"\n",
|
||
{ mode: 0o755 },
|
||
);
|
||
process.env.PATH = `${binDir}:${originalPath ?? ""}`;
|
||
|
||
const pathClobberingShell = new PolyglotExecutor({
|
||
runtimes: { ...runtimes, shell: fakeShell },
|
||
});
|
||
const r = await pathClobberingShell.execute({
|
||
language: "shell",
|
||
code: "command -v ctx-path-probe\nctx-path-probe",
|
||
timeout: 5_000,
|
||
});
|
||
|
||
assert.equal(r.exitCode, 0, r.stderr);
|
||
assert.ok(r.stdout.includes(fakeBin), `Expected ${fakeBin}, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("probe-ok"), `Got: ${r.stdout}`);
|
||
} finally {
|
||
if (originalPath === undefined) delete process.env.PATH;
|
||
else process.env.PATH = originalPath;
|
||
rmSync(testDir, { recursive: true, force: true });
|
||
}
|
||
},
|
||
);
|
||
|
||
test("shell TMPDIR points to sandbox temp dir, not project root", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "$TMPDIR"',
|
||
timeout: 5_000,
|
||
});
|
||
const reported = r.stdout.trim();
|
||
assert.ok(
|
||
!reported.startsWith(process.cwd()),
|
||
`TMPDIR should not be project root, got: ${reported}`,
|
||
);
|
||
assert.ok(
|
||
reported.includes(".ctx-mode-"),
|
||
`TMPDIR should be the sandbox temp dir, got: ${reported}`,
|
||
);
|
||
});
|
||
|
||
test("shell TMPDIR works cross-platform (not just echo)", async () => {
|
||
// Use a command that writes to TMPDIR to verify it's writable
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'touch "$TMPDIR/ctx-test-file" && echo "ok"',
|
||
timeout: 5_000,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.trim() === "ok");
|
||
});
|
||
|
||
test("Shell: for loop + wc", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'for i in 1 2 3 4 5; do echo "item $i"; done | wc -l | tr -d " "',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.trim() === "5");
|
||
});
|
||
|
||
test("Shell: awk processing", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: `printf "Alice 30\\nBob 25\\nCharlie 35" | awk '{sum += $2; count++} END {print "avg:", sum/count}'`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("avg: 30"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.ruby)("Ruby Execution", () => {
|
||
test("Ruby: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "ruby",
|
||
code: 'puts "hello from ruby"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from ruby"));
|
||
});
|
||
|
||
test("Ruby: array methods", async () => {
|
||
const r = await executor.execute({
|
||
language: "ruby",
|
||
code: `
|
||
nums = (1..10).to_a
|
||
evens = nums.select { |n| n.even? }
|
||
puts "evens: #{evens.join(', ')}"
|
||
puts "sum: #{evens.sum}"
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 30"));
|
||
});
|
||
|
||
test("Ruby: hash + JSON", async () => {
|
||
const r = await executor.execute({
|
||
language: "ruby",
|
||
code: `
|
||
require 'json'
|
||
data = { users: [{ name: "Alice" }, { name: "Bob" }] }
|
||
puts JSON.generate({ count: data[:users].length })
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
const out = JSON.parse(r.stdout.trim());
|
||
assert.equal(out.count, 2);
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.go)("Go Execution", () => {
|
||
test("Go: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "go",
|
||
code: 'fmt.Println("hello from go")',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from go"));
|
||
});
|
||
|
||
test("Go: loops + slices", async () => {
|
||
const r = await executor.execute({
|
||
language: "go",
|
||
code: `
|
||
nums := []int{1, 2, 3, 4, 5}
|
||
sum := 0
|
||
for _, n := range nums {
|
||
sum += n
|
||
}
|
||
fmt.Println("sum:", sum)
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 15"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.php)("PHP Execution", () => {
|
||
test("PHP: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "php",
|
||
code: 'echo "hello from php\\n";',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from php"));
|
||
});
|
||
|
||
test("PHP: array functions", async () => {
|
||
const r = await executor.execute({
|
||
language: "php",
|
||
code: `
|
||
$nums = range(1, 10);
|
||
$evens = array_filter($nums, fn($n) => $n % 2 === 0);
|
||
echo "evens: " . implode(", ", $evens) . "\\n";
|
||
echo "sum: " . array_sum($evens) . "\\n";
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 30"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.perl)("Perl Execution", () => {
|
||
test("Perl: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "perl",
|
||
code: 'print "hello from perl\\n";',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from perl"));
|
||
});
|
||
|
||
test("Perl: regex + array", async () => {
|
||
const r = await executor.execute({
|
||
language: "perl",
|
||
code: `
|
||
my @words = ("apple", "banana", "avocado", "blueberry");
|
||
my @a_words = grep { /^a/i } @words;
|
||
print "a-words: @a_words\\n";
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("apple"));
|
||
assert.ok(r.stdout.includes("avocado"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.r)("R Execution", () => {
|
||
test("R: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "r",
|
||
code: 'cat("hello from R\\n")',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from R"));
|
||
});
|
||
|
||
test("R: vector operations", async () => {
|
||
const r = await executor.execute({
|
||
language: "r",
|
||
code: `
|
||
nums <- 1:10
|
||
cat("mean:", mean(nums), "\\n")
|
||
cat("sum:", sum(nums), "\\n")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 55"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.elixir)("Elixir Execution", () => {
|
||
test("Elixir: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "elixir",
|
||
code: 'IO.puts("hello from elixir")',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("hello from elixir"));
|
||
});
|
||
|
||
test("Elixir: list operations + Enum", async () => {
|
||
const r = await executor.execute({
|
||
language: "elixir",
|
||
code: `
|
||
nums = Enum.to_list(1..10)
|
||
evens = Enum.filter(nums, fn n -> rem(n, 2) == 0 end)
|
||
IO.puts("evens: #{Enum.join(evens, ", ")}")
|
||
IO.puts("sum: #{Enum.sum(evens)}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("sum: 30"));
|
||
});
|
||
|
||
test("Elixir: map + pattern matching", async () => {
|
||
const r = await executor.execute({
|
||
language: "elixir",
|
||
code: `
|
||
users = [%{name: "Alice", role: "admin"}, %{name: "Bob", role: "user"}]
|
||
admins = Enum.filter(users, fn %{role: role} -> role == "admin" end)
|
||
IO.puts("admins: #{length(admins)}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("admins: 1"));
|
||
});
|
||
|
||
test("Elixir: pipe operator + String functions", async () => {
|
||
const r = await executor.execute({
|
||
language: "elixir",
|
||
code: `
|
||
result =
|
||
"hello world from elixir"
|
||
|> String.split()
|
||
|> Enum.map(&String.upcase/1)
|
||
|> Enum.join(" ")
|
||
IO.puts(result)
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("HELLO WORLD FROM ELIXIR"));
|
||
});
|
||
|
||
test("Elixir: error raises non-zero exit", async () => {
|
||
const r = await executor.execute({
|
||
language: "elixir",
|
||
code: 'raise "intentional error"',
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
assert.ok(r.stderr.length > 0 || r.stdout.includes("intentional error"));
|
||
});
|
||
});
|
||
|
||
describe.runIf(runtimes.csharp)("CSharp Execution", () => {
|
||
test("C#: hello world", async () => {
|
||
const r = await executor.execute({
|
||
language: "csharp",
|
||
code: 'Console.WriteLine("hello from csharp");',
|
||
});
|
||
assert.equal(r.exitCode, 0, r.stderr);
|
||
assert.ok(r.stdout.includes("hello from csharp"));
|
||
});
|
||
|
||
test("C#: LINQ over array", async () => {
|
||
const r = await executor.execute({
|
||
language: "csharp",
|
||
code: `
|
||
using System.Linq;
|
||
var nums = Enumerable.Range(1, 10);
|
||
var evens = nums.Where(n => n % 2 == 0).ToArray();
|
||
Console.WriteLine($"sum: {evens.Sum()}");
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0, r.stderr);
|
||
assert.ok(r.stdout.includes("sum: 30"));
|
||
});
|
||
|
||
test("C#: error returns non-zero exit", async () => {
|
||
const r = await executor.execute({
|
||
language: "csharp",
|
||
code: 'throw new System.Exception("intentional error");',
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
assert.ok(r.stderr.length > 0 || r.stdout.includes("intentional error"));
|
||
});
|
||
});
|
||
|
||
describe("Error Handling", () => {
|
||
test("JS: syntax error returns non-zero", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: "const x = {",
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
assert.ok(r.stderr.length > 0);
|
||
});
|
||
|
||
test("JS: runtime error returns non-zero", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'throw new Error("intentional");',
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
assert.ok(r.stderr.includes("intentional"));
|
||
});
|
||
|
||
test.runIf(runtimes.python)("Python: syntax error", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: "def foo(\n pass",
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
});
|
||
|
||
test.runIf(runtimes.python)("Python: runtime error (ValueError)", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: 'raise ValueError("test error")',
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
assert.ok(r.stderr.includes("ValueError"));
|
||
});
|
||
|
||
test("Shell: non-zero exit code preserved", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: "exit 42",
|
||
});
|
||
assert.equal(r.exitCode, 42);
|
||
});
|
||
|
||
test("Shell: command not found", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: "nonexistent_command_xyz 2>&1",
|
||
});
|
||
assert.notEqual(r.exitCode, 0);
|
||
});
|
||
});
|
||
|
||
describe("Timeout Handling", () => {
|
||
test("JS: infinite loop times out", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: "while(true) {}",
|
||
timeout: 500,
|
||
});
|
||
assert.equal(r.timedOut, true);
|
||
});
|
||
|
||
// Issue #406 — when timeout omitted, no server-side timer fires and a
|
||
// long-running process completes naturally. Caller (or MCP host) owns
|
||
// the timeout policy.
|
||
test("JS: no timeout — short script completes without forced kill", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
// 250ms wait then print — caller didn't pass timeout, so we must
|
||
// wait for natural exit, not kill at any heuristic ceiling.
|
||
code: "setTimeout(() => { console.log('done'); }, 250);",
|
||
});
|
||
assert.equal(r.timedOut, false);
|
||
assert.equal(r.stdout.trim(), "done");
|
||
});
|
||
|
||
test("Shell: no timeout — sleep 1 completes without forced kill", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: "sleep 1 && echo done",
|
||
});
|
||
assert.equal(r.timedOut, false);
|
||
assert.equal(r.stdout.trim(), "done");
|
||
});
|
||
|
||
test("JS: infinite loop leaves no orphaned process after kill", async () => {
|
||
// Spawn a process that writes its PID then loops forever
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: `process.stdout.write(String(process.pid)); while(true) {}`,
|
||
timeout: 1000,
|
||
});
|
||
assert.equal(r.timedOut, true);
|
||
const pid = parseInt(r.stdout.trim(), 10);
|
||
assert.ok(pid > 0, `Expected valid PID in stdout, got: "${r.stdout}"`);
|
||
// Give OS a moment to reap
|
||
await new Promise(r => setTimeout(r, 200));
|
||
let alive = false;
|
||
try {
|
||
process.kill(pid, 0); // signal 0 = check if alive
|
||
alive = true;
|
||
} catch { /* ESRCH = not found = good */ }
|
||
assert.equal(alive, false, `Process ${pid} should be dead after timeout kill`);
|
||
}, 10_000);
|
||
|
||
test("JS: child processes are killed with parent (no orphans)", async () => {
|
||
// Parent spawns a child that writes its PID to stderr, then both loop
|
||
const code = `
|
||
const { fork } = require("child_process");
|
||
if (process.env.__CHILD__) {
|
||
process.stderr.write(String(process.pid));
|
||
while(true) {}
|
||
} else {
|
||
process.stdout.write(String(process.pid));
|
||
const env = { ...process.env, __CHILD__: "1" };
|
||
fork(process.argv[1], { env });
|
||
while(true) {}
|
||
}
|
||
`;
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code,
|
||
timeout: 1500,
|
||
});
|
||
assert.equal(r.timedOut, true);
|
||
const parentPid = parseInt(r.stdout.trim(), 10);
|
||
const childPid = parseInt(r.stderr.trim(), 10);
|
||
assert.ok(parentPid > 0, `Expected parent PID, got: "${r.stdout}"`);
|
||
assert.ok(childPid > 0, `Expected child PID, got: "${r.stderr}"`);
|
||
await new Promise(r => setTimeout(r, 200));
|
||
for (const pid of [parentPid, childPid]) {
|
||
let alive = false;
|
||
try { process.kill(pid, 0); alive = true; } catch {}
|
||
assert.equal(alive, false, `Process ${pid} should be dead after group kill`);
|
||
}
|
||
}, 10_000);
|
||
|
||
test("Shell: sleep times out", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: "sleep 5",
|
||
timeout: 500,
|
||
});
|
||
assert.equal(r.timedOut, true);
|
||
}, 10_000);
|
||
|
||
test.runIf(runtimes.python)("Python: infinite sleep times out", async () => {
|
||
const r = await executor.execute({
|
||
language: "python",
|
||
code: "import time; time.sleep(5)",
|
||
timeout: 500,
|
||
});
|
||
assert.equal(r.timedOut, true);
|
||
});
|
||
});
|
||
|
||
describe("Output Truncation", () => {
|
||
test("stdout is returned in full without truncation", async () => {
|
||
const small = new PolyglotExecutor({ runtimes });
|
||
const r = await small.execute({
|
||
language: "javascript",
|
||
code: 'for (let i = 0; i < 100; i++) console.log(`line ${i}: ${"x".repeat(20)}`);',
|
||
});
|
||
assert.ok(!r.stdout.includes("truncated"), "stdout should NOT be truncated");
|
||
assert.ok(r.stdout.includes("line 0"), "Should contain first line");
|
||
assert.ok(r.stdout.includes("line 50"), "Should contain middle line (previously lost)");
|
||
assert.ok(r.stdout.includes("line 99"), "Should contain last line");
|
||
assert.ok(!r.stdout.includes("showing first"), "Should have no truncation marker");
|
||
});
|
||
|
||
test("does not truncate under limit", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'console.log("small output");',
|
||
});
|
||
assert.ok(!r.stdout.includes("truncated"));
|
||
});
|
||
|
||
test("stderr is also returned in full without truncation", async () => {
|
||
const small = new PolyglotExecutor({ runtimes });
|
||
const r = await small.execute({
|
||
language: "javascript",
|
||
code: `
|
||
for (let i = 0; i < 50; i++) console.error("warn " + i);
|
||
console.error("FINAL ERROR: something broke");
|
||
`,
|
||
});
|
||
assert.ok(r.stderr.includes("FINAL ERROR"), "Should contain last error line");
|
||
assert.ok(r.stderr.includes("warn 0"), "Should contain first warning");
|
||
assert.ok(r.stderr.includes("warn 25"), "Should contain middle warning (previously lost)");
|
||
assert.ok(!r.stderr.includes("truncated"), "stderr should NOT be truncated");
|
||
});
|
||
});
|
||
|
||
describe("execute_file (FILE_CONTENT)", () => {
|
||
const testDir = join(tmpdir(), "ctx-mode-test-" + Date.now());
|
||
mkdirSync(testDir, { recursive: true });
|
||
const testFile = join(testDir, "test-data.json");
|
||
writeFileSync(
|
||
testFile,
|
||
JSON.stringify({
|
||
users: [
|
||
{ name: "Alice", role: "admin" },
|
||
{ name: "Bob", role: "user" },
|
||
{ name: "Charlie", role: "admin" },
|
||
],
|
||
}),
|
||
"utf-8",
|
||
);
|
||
|
||
test("execute_file: JS reads FILE_CONTENT", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "javascript",
|
||
code: `
|
||
const data = JSON.parse(FILE_CONTENT);
|
||
const admins = data.users.filter(u => u.role === "admin");
|
||
console.log("admins: " + admins.map(a => a.name).join(", "));
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("admins: Alice, Charlie"));
|
||
});
|
||
|
||
test.runIf(runtimes.python)("execute_file: Python reads FILE_CONTENT", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "python",
|
||
code: `
|
||
import json
|
||
data = json.loads(FILE_CONTENT)
|
||
print(f"Users: {len(data['users'])}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("Users: 3"));
|
||
});
|
||
|
||
test("execute_file: Shell reads FILE_CONTENT", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "shell",
|
||
code: 'echo "size: ${#FILE_CONTENT} bytes"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("bytes"));
|
||
});
|
||
|
||
test.runIf(runtimes.ruby)("execute_file: Ruby reads FILE_CONTENT", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "ruby",
|
||
code: `
|
||
require 'json'
|
||
data = JSON.parse(FILE_CONTENT)
|
||
puts "Users: #{data['users'].length}"
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("Users: 3"));
|
||
});
|
||
|
||
test.runIf(runtimes.csharp)("execute_file: C# reads FILE_CONTENT", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "csharp",
|
||
code: `
|
||
using (var doc = System.Text.Json.JsonDocument.Parse(FILE_CONTENT)) {
|
||
var users = doc.RootElement.GetProperty("users").GetArrayLength();
|
||
System.Console.WriteLine($"Users: {users}");
|
||
}
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0, r.stderr);
|
||
assert.ok(r.stdout.includes("Users: 3"));
|
||
});
|
||
|
||
// --- execute_file: shell $ expansion in paths ---
|
||
|
||
const dollarDir = join(testDir, "path$SHOULD_NOT_EXPAND");
|
||
mkdirSync(dollarDir, { recursive: true });
|
||
const dollarFile = join(dollarDir, "data.txt");
|
||
writeFileSync(dollarFile, "dollar-sign-content", "utf-8");
|
||
|
||
test("execute_file: Shell path with $ is not expanded", async () => {
|
||
const r = await executor.executeFile({
|
||
path: dollarFile,
|
||
language: "shell",
|
||
code: 'echo "content: $FILE_CONTENT"',
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(
|
||
r.stdout.includes("content: dollar-sign-content"),
|
||
`Expected literal file content, got: ${r.stdout}`,
|
||
);
|
||
});
|
||
|
||
// --- execute_file: shell paths with spaces ---
|
||
|
||
const spaceDir = join(testDir, "path with spaces");
|
||
mkdirSync(spaceDir, { recursive: true });
|
||
const spaceFile = join(spaceDir, "space file.txt");
|
||
writeFileSync(spaceFile, "space-content", "utf-8");
|
||
|
||
test("execute_file: Shell path with spaces works correctly", async () => {
|
||
const r = await executor.executeFile({
|
||
path: spaceFile,
|
||
language: "shell",
|
||
code: 'echo "content: $FILE_CONTENT"',
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(
|
||
r.stdout.includes("content: space-content"),
|
||
`Expected literal file content, got: ${r.stdout}`,
|
||
);
|
||
});
|
||
|
||
// --- execute_file: shell paths with single quotes ---
|
||
|
||
const quoteDir = join(testDir, "it's-a-dir");
|
||
mkdirSync(quoteDir, { recursive: true });
|
||
const quoteFile = join(quoteDir, "quote.txt");
|
||
writeFileSync(quoteFile, "quote-content", "utf-8");
|
||
|
||
test("execute_file: Shell path with single quotes works correctly", async () => {
|
||
const r = await executor.executeFile({
|
||
path: quoteFile,
|
||
language: "shell",
|
||
code: 'echo "content: $FILE_CONTENT"',
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(
|
||
r.stdout.includes("content: quote-content"),
|
||
`Expected literal file content, got: ${r.stdout}`,
|
||
);
|
||
});
|
||
|
||
// --- execute_file: shell paths with backticks ---
|
||
|
||
const backtickDir = join(testDir, "dir`tick");
|
||
mkdirSync(backtickDir, { recursive: true });
|
||
const backtickFile = join(backtickDir, "bt.txt");
|
||
writeFileSync(backtickFile, "backtick-content", "utf-8");
|
||
|
||
test("execute_file: Shell path with backticks is not executed", async () => {
|
||
const r = await executor.executeFile({
|
||
path: backtickFile,
|
||
language: "shell",
|
||
code: 'echo "content: $FILE_CONTENT"',
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(
|
||
r.stdout.includes("content: backtick-content"),
|
||
`Expected literal file content, got: ${r.stdout}`,
|
||
);
|
||
});
|
||
|
||
// --- execute_file: shell paths with combined special characters ---
|
||
|
||
const comboDir = join(testDir, "$HOME has `spaces` & 'quotes'");
|
||
mkdirSync(comboDir, { recursive: true });
|
||
const comboFile = join(comboDir, "combo.txt");
|
||
writeFileSync(comboFile, "combo-content", "utf-8");
|
||
|
||
test("execute_file: Shell path with combined special chars works", async () => {
|
||
const r = await executor.executeFile({
|
||
path: comboFile,
|
||
language: "shell",
|
||
code: 'echo "content: $FILE_CONTENT"',
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(
|
||
r.stdout.includes("content: combo-content"),
|
||
`Expected literal file content, got: ${r.stdout}`,
|
||
);
|
||
});
|
||
|
||
test.runIf(runtimes.elixir)("execute_file: Elixir reads file_content", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "elixir",
|
||
code: `
|
||
IO.puts("file size: #{byte_size(file_content)}")
|
||
IO.puts("has users: #{String.contains?(file_content, "users")}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("has users: true"));
|
||
});
|
||
|
||
// --- UTF-8 / Non-ASCII file content ---
|
||
const utf8File = join(testDir, "utf8-data.txt");
|
||
writeFileSync(utf8File, "这是中文内容\n日本語テスト\n한국어\nEmoji: 🔒✅\nLine 5", "utf-8");
|
||
|
||
test("execute_file: Python reads UTF-8 non-ASCII content", async () => {
|
||
const r = await executor.executeFile({
|
||
path: utf8File,
|
||
language: "python",
|
||
code: `
|
||
lines = FILE_CONTENT.strip().split('\\n')
|
||
print(f"lines: {len(lines)}")
|
||
print(f"first: {lines[0]}")
|
||
print(f"has_emoji: {'🔒' in FILE_CONTENT}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0, "Python UTF-8 exit code: " + r.stderr);
|
||
assert.ok(r.stdout.includes("lines: 5"), "Should have 5 lines");
|
||
assert.ok(r.stdout.includes("first: 这是中文内容"), "Should read Chinese");
|
||
assert.ok(r.stdout.includes("has_emoji: True"), "Should find emoji");
|
||
});
|
||
|
||
test("execute_file: JS reads UTF-8 non-ASCII content", async () => {
|
||
const r = await executor.executeFile({
|
||
path: utf8File,
|
||
language: "javascript",
|
||
code: `
|
||
const lines = FILE_CONTENT.trim().split('\\n');
|
||
console.log("lines: " + lines.length);
|
||
console.log("first: " + lines[0]);
|
||
console.log("has_emoji: " + FILE_CONTENT.includes('🔒'));
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("lines: 5"));
|
||
assert.ok(r.stdout.includes("first: 这是中文内容"));
|
||
assert.ok(r.stdout.includes("has_emoji: true"));
|
||
});
|
||
|
||
test.runIf(runtimes.ruby)("execute_file: Ruby reads UTF-8 non-ASCII content", async () => {
|
||
const r = await executor.executeFile({
|
||
path: utf8File,
|
||
language: "ruby",
|
||
code: `
|
||
lines = FILE_CONTENT.strip.split("\\n")
|
||
puts "lines: #{lines.length}"
|
||
puts "first: #{lines[0]}"
|
||
puts "has_emoji: #{FILE_CONTENT.include?('🔒')}"
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0, "Ruby UTF-8 exit code: " + r.stderr);
|
||
assert.ok(r.stdout.includes("lines: 5"));
|
||
assert.ok(r.stdout.includes("first: 这是中文内容"));
|
||
});
|
||
|
||
test("execute_file: Shell reads UTF-8 non-ASCII content", async () => {
|
||
const r = await executor.executeFile({
|
||
path: utf8File,
|
||
language: "shell",
|
||
code: 'echo "$FILE_CONTENT" | head -1',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("这是中文内容"), "Shell should read Chinese: " + r.stdout);
|
||
});
|
||
|
||
// --- execute_file: file_path alias ---
|
||
|
||
test.runIf(runtimes.python)("execute_file: Python exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "python",
|
||
code: `
|
||
import json
|
||
with open(file_path) as f:
|
||
data = json.load(f)
|
||
print(f"Users via file_path: {len(data['users'])}")
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes("Users via file_path: 3"), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test("execute_file: JS exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "javascript",
|
||
code: `console.log("file_path alias: " + file_path);`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test("execute_file: TypeScript exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "typescript",
|
||
code: `console.log("file_path alias: " + file_path);`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test("execute_file: Shell exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "shell",
|
||
code: 'echo "file_path alias: $file_path"',
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.ruby)("execute_file: Ruby exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "ruby",
|
||
code: `
|
||
require 'json'
|
||
data = JSON.parse(File.read(file_path))
|
||
puts "Users via file_path: #{data['users'].length}"
|
||
`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes("Users via file_path: 3"), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.go)("execute_file: Go exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "go",
|
||
code: `fmt.Println("file_path alias: " + file_path)`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.rust)("execute_file: Rust exposes 'file_path' as alias for file_content_path", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "rust",
|
||
code: `println!("file_path alias: {}", file_path);`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.php)("execute_file: PHP exposes '$file_path' as alias for $FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "php",
|
||
code: `echo "file_path alias: " . $file_path . "\\n";`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.perl)("execute_file: Perl exposes '$file_path' as alias for $FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "perl",
|
||
code: `print "file_path alias: $file_path\\n";`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.r)("execute_file: R exposes 'file_path' as alias for FILE_CONTENT_PATH", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "r",
|
||
code: `cat(paste0("file_path alias: ", file_path, "\\n"))`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
test.runIf(runtimes.elixir)("execute_file: Elixir exposes 'file_path' as alias for file_content_path", async () => {
|
||
const r = await executor.executeFile({
|
||
path: testFile,
|
||
language: "elixir",
|
||
code: `IO.puts("file_path alias: " <> file_path)`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(testFile), `Got: ${r.stdout}`);
|
||
});
|
||
|
||
afterAll(() => {
|
||
rmSync(testDir, { recursive: true, force: true });
|
||
});
|
||
});
|
||
|
||
describe("Environment Passthrough", () => {
|
||
test("SSH_AUTH_SOCK is passed through to subprocess when set", async () => {
|
||
const original = process.env.SSH_AUTH_SOCK;
|
||
process.env.SSH_AUTH_SOCK = "/tmp/test-ssh-agent.sock";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(
|
||
r.stdout.includes("/tmp/test-ssh-agent.sock"),
|
||
`Expected SSH_AUTH_SOCK to be passed through, got: ${r.stdout}`,
|
||
);
|
||
} finally {
|
||
if (original === undefined) delete process.env.SSH_AUTH_SOCK;
|
||
else process.env.SSH_AUTH_SOCK = original;
|
||
}
|
||
});
|
||
|
||
test("SSH_AGENT_PID is passed through to subprocess when set", async () => {
|
||
const original = process.env.SSH_AGENT_PID;
|
||
process.env.SSH_AGENT_PID = "99999";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "SSH_AGENT_PID=$SSH_AGENT_PID"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(
|
||
r.stdout.includes("99999"),
|
||
`Expected SSH_AGENT_PID to be passed through, got: ${r.stdout}`,
|
||
);
|
||
} finally {
|
||
if (original === undefined) delete process.env.SSH_AGENT_PID;
|
||
else process.env.SSH_AGENT_PID = original;
|
||
}
|
||
});
|
||
|
||
test("SSH_AUTH_SOCK is absent from subprocess when not set in parent", async () => {
|
||
const original = process.env.SSH_AUTH_SOCK;
|
||
delete process.env.SSH_AUTH_SOCK;
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'if [ -z "${SSH_AUTH_SOCK+x}" ]; then echo "unset"; else echo "set=$SSH_AUTH_SOCK"; fi',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(
|
||
r.stdout.includes("unset"),
|
||
`Expected SSH_AUTH_SOCK to be absent, got: ${r.stdout}`,
|
||
);
|
||
} finally {
|
||
if (original !== undefined) process.env.SSH_AUTH_SOCK = original;
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Environment Denylist", () => {
|
||
test("dangerous vars are stripped from subprocess (BASH_ENV, NODE_OPTIONS)", async () => {
|
||
const origBash = process.env.BASH_ENV;
|
||
const origNode = process.env.NODE_OPTIONS;
|
||
process.env.BASH_ENV = "/tmp/evil.sh";
|
||
process.env.NODE_OPTIONS = "--inspect";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "BASH_ENV=${BASH_ENV:-unset}" && echo "NODE_OPTIONS=${NODE_OPTIONS:-unset}"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("BASH_ENV=unset"), `BASH_ENV should be stripped, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("NODE_OPTIONS=unset"), `NODE_OPTIONS should be stripped, got: ${r.stdout}`);
|
||
} finally {
|
||
if (origBash === undefined) delete process.env.BASH_ENV;
|
||
else process.env.BASH_ENV = origBash;
|
||
if (origNode === undefined) delete process.env.NODE_OPTIONS;
|
||
else process.env.NODE_OPTIONS = origNode;
|
||
}
|
||
});
|
||
|
||
test("dangerous vars are stripped: PERL5OPT, RUBYOPT, LD_PRELOAD", async () => {
|
||
const origPerl = process.env.PERL5OPT;
|
||
const origRuby = process.env.RUBYOPT;
|
||
const origLD = process.env.LD_PRELOAD;
|
||
process.env.PERL5OPT = "-Mbase";
|
||
process.env.RUBYOPT = "-rmalicious";
|
||
process.env.LD_PRELOAD = "/tmp/evil.so";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "PERL5OPT=${PERL5OPT:-unset}" && echo "RUBYOPT=${RUBYOPT:-unset}" && echo "LD_PRELOAD=${LD_PRELOAD:-unset}"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("PERL5OPT=unset"), `PERL5OPT should be stripped, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("RUBYOPT=unset"), `RUBYOPT should be stripped, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("LD_PRELOAD=unset"), `LD_PRELOAD should be stripped, got: ${r.stdout}`);
|
||
} finally {
|
||
if (origPerl === undefined) delete process.env.PERL5OPT;
|
||
else process.env.PERL5OPT = origPerl;
|
||
if (origRuby === undefined) delete process.env.RUBYOPT;
|
||
else process.env.RUBYOPT = origRuby;
|
||
if (origLD === undefined) delete process.env.LD_PRELOAD;
|
||
else process.env.LD_PRELOAD = origLD;
|
||
}
|
||
});
|
||
|
||
test("user env vars pass through by default (no allowlist needed)", async () => {
|
||
const origSlack = process.env.SLACK_BOT_TOKEN;
|
||
const origCustom = process.env.MY_CUSTOM_API_KEY;
|
||
process.env.SLACK_BOT_TOKEN = "xoxb-test-token";
|
||
process.env.MY_CUSTOM_API_KEY = "custom-12345";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "SLACK=$SLACK_BOT_TOKEN" && echo "CUSTOM=$MY_CUSTOM_API_KEY"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("SLACK=xoxb-test-token"), `SLACK_BOT_TOKEN should pass through, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("CUSTOM=custom-12345"), `MY_CUSTOM_API_KEY should pass through, got: ${r.stdout}`);
|
||
} finally {
|
||
if (origSlack === undefined) delete process.env.SLACK_BOT_TOKEN;
|
||
else process.env.SLACK_BOT_TOKEN = origSlack;
|
||
if (origCustom === undefined) delete process.env.MY_CUSTOM_API_KEY;
|
||
else process.env.MY_CUSTOM_API_KEY = origCustom;
|
||
}
|
||
});
|
||
|
||
test("sandbox overrides take precedence over parent env", async () => {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "NO_COLOR=$NO_COLOR" && echo "PYTHONUNBUFFERED=$PYTHONUNBUFFERED"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("NO_COLOR=1"), `NO_COLOR should be forced to 1, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("PYTHONUNBUFFERED=1"), `PYTHONUNBUFFERED should be forced to 1, got: ${r.stdout}`);
|
||
});
|
||
|
||
// PR #546 follow-up: Microsoft documents native injection vectors that
|
||
// hijack the dotnet host. Profiler attach loads an arbitrary DLL into the
|
||
// host process; DiagnosticPorts allows a peer to attach a debugger /
|
||
// inject IL via the IPC port; the bundle extract dir lets a
|
||
// single-file app extraction be redirected. Every DOTNET_* knob has a
|
||
// COMPlus_* synonym (back-compat alias). Strip the explicit set and
|
||
// sweep COMPlus_* via prefix.
|
||
// Refs: learn.microsoft.com/en-us/dotnet/core/runtime-config/{debugging-profiling,dotnet-environment-variables}
|
||
test("DOTNET profiler + DiagnosticPorts + COMPlus aliases are stripped", async () => {
|
||
const KEYS = [
|
||
"CORECLR_PROFILER",
|
||
"CORECLR_PROFILER_PATH",
|
||
"CORECLR_PROFILER_PATH_32",
|
||
"CORECLR_PROFILER_PATH_64",
|
||
"CORECLR_PROFILER_PATH_ARM32",
|
||
"CORECLR_PROFILER_PATH_ARM64",
|
||
"DOTNET_PROFILER_PATH",
|
||
"DOTNET_PROFILER_PATH_32",
|
||
"DOTNET_PROFILER_PATH_64",
|
||
"DOTNET_PROFILER_PATH_ARM32",
|
||
"DOTNET_PROFILER_PATH_ARM64",
|
||
"CORECLR_ENABLE_PROFILING",
|
||
"DOTNET_DiagnosticPorts",
|
||
"DOTNET_BUNDLE_EXTRACT_BASE_DIR",
|
||
// COMPlus_* alias coverage — sweep via /^COMPlus_/i prefix.
|
||
"COMPlus_EnableDiagnostics",
|
||
"COMPlus_DbgEnableMiniDump",
|
||
"COMPlus_TieredCompilation",
|
||
];
|
||
const saved: Record<string, string | undefined> = {};
|
||
for (const k of KEYS) {
|
||
saved[k] = process.env[k];
|
||
process.env[k] = "evil-injected";
|
||
}
|
||
try {
|
||
const echoes = KEYS.map((k) => `echo "${k}=\${${k}:-unset}"`).join(" && ");
|
||
const r = await executor.execute({ language: "shell", code: echoes });
|
||
assert.equal(r.exitCode, 0);
|
||
for (const k of KEYS) {
|
||
assert.ok(
|
||
r.stdout.includes(`${k}=unset`),
|
||
`${k} should be stripped, got: ${r.stdout}`,
|
||
);
|
||
}
|
||
} finally {
|
||
for (const k of KEYS) {
|
||
if (saved[k] === undefined) delete process.env[k];
|
||
else process.env[k] = saved[k];
|
||
}
|
||
}
|
||
});
|
||
|
||
test("CLAUDE_PLUGIN_ROOT and unrelated env vars survive after DOTNET strip", async () => {
|
||
// Defense-in-depth — make sure the new explicit denylist + COMPlus_*
|
||
// sweep do not accidentally clobber unrelated env. CLAUDE_PLUGIN_ROOT
|
||
// is the canary because the hooks rely on it.
|
||
const origRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
||
const origUnrelated = process.env.MY_HARMLESS_VAR;
|
||
process.env.CLAUDE_PLUGIN_ROOT = "/test/plugin/root";
|
||
process.env.MY_HARMLESS_VAR = "stay-alive";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code:
|
||
'echo "CLAUDE_PLUGIN_ROOT=$CLAUDE_PLUGIN_ROOT" && echo "MY_HARMLESS_VAR=$MY_HARMLESS_VAR"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(
|
||
r.stdout.includes("CLAUDE_PLUGIN_ROOT=/test/plugin/root"),
|
||
`CLAUDE_PLUGIN_ROOT must survive, got: ${r.stdout}`,
|
||
);
|
||
assert.ok(
|
||
r.stdout.includes("MY_HARMLESS_VAR=stay-alive"),
|
||
`MY_HARMLESS_VAR must survive, got: ${r.stdout}`,
|
||
);
|
||
} finally {
|
||
if (origRoot === undefined) delete process.env.CLAUDE_PLUGIN_ROOT;
|
||
else process.env.CLAUDE_PLUGIN_ROOT = origRoot;
|
||
if (origUnrelated === undefined) delete process.env.MY_HARMLESS_VAR;
|
||
else process.env.MY_HARMLESS_VAR = origUnrelated;
|
||
}
|
||
});
|
||
|
||
test("ERL_AFLAGS and ERL_FLAGS are stripped", async () => {
|
||
const origA = process.env.ERL_AFLAGS;
|
||
const origF = process.env.ERL_FLAGS;
|
||
process.env.ERL_AFLAGS = "-eval 'os:cmd(\"id\")'";
|
||
process.env.ERL_FLAGS = "-eval 'os:cmd(\"id\")'";
|
||
try {
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: 'echo "ERL_AFLAGS=${ERL_AFLAGS:-unset}" && echo "ERL_FLAGS=${ERL_FLAGS:-unset}"',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("ERL_AFLAGS=unset"), `ERL_AFLAGS should be stripped, got: ${r.stdout}`);
|
||
assert.ok(r.stdout.includes("ERL_FLAGS=unset"), `ERL_FLAGS should be stripped, got: ${r.stdout}`);
|
||
} finally {
|
||
if (origA === undefined) delete process.env.ERL_AFLAGS;
|
||
else process.env.ERL_AFLAGS = origA;
|
||
if (origF === undefined) delete process.env.ERL_FLAGS;
|
||
else process.env.ERL_FLAGS = origF;
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Concurrent Execution", () => {
|
||
test("5 concurrent JS executions", async () => {
|
||
const promises = Array.from({ length: 5 }, (_, i) =>
|
||
executor.execute({
|
||
language: "javascript",
|
||
code: `console.log("concurrent ${i}");`,
|
||
}),
|
||
);
|
||
const all = await Promise.all(promises);
|
||
for (const r of all) {
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("concurrent"));
|
||
}
|
||
});
|
||
|
||
test("mixed language concurrent execution", async () => {
|
||
const promises = [
|
||
executor.execute({
|
||
language: "javascript",
|
||
code: 'console.log("js");',
|
||
}),
|
||
executor.execute({ language: "shell", code: 'echo "sh"' }),
|
||
];
|
||
promises.push(
|
||
executor.execute({ language: "python", code: 'print("py")' }),
|
||
);
|
||
const all = await Promise.all(promises);
|
||
for (const r of all) {
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.trim().length > 0);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Edge Cases", () => {
|
||
test("empty output returns empty string", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: "// no output",
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.equal(r.stdout, "");
|
||
});
|
||
|
||
test("multiline output preserved", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: "for (let i = 0; i < 10; i++) console.log(`line ${i}`);",
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.equal(r.stdout.trim().split("\n").length, 10);
|
||
});
|
||
|
||
test("stderr captured separately from stdout", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'console.error("warning"); console.log("ok");',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("ok"));
|
||
assert.ok(r.stderr.includes("warning"));
|
||
});
|
||
|
||
test("special characters in output", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'console.log("line1\\nline2\\ttab\\n\\"quoted\\"");',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("line1"));
|
||
assert.ok(r.stdout.includes("line2"));
|
||
assert.ok(r.stdout.includes('"quoted"'));
|
||
});
|
||
|
||
test("unicode output", async () => {
|
||
const r = await executor.execute({
|
||
language: "javascript",
|
||
code: 'console.log("Hello world");',
|
||
});
|
||
assert.equal(r.exitCode, 0);
|
||
assert.ok(r.stdout.includes("Hello"));
|
||
});
|
||
});
|
||
|
||
describe("Temp Cleanup Resilience", () => {
|
||
test("concurrent executions all return valid results (EBUSY resilience)", async () => {
|
||
const count = 15;
|
||
const promises = Array.from({ length: count }, (_, i) =>
|
||
executor.execute({
|
||
language: "javascript",
|
||
code: `
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
for (let j = 0; j < 3; j++) {
|
||
fs.writeFileSync(path.join(process.cwd(), 'f' + j + '.tmp'), 'data');
|
||
}
|
||
console.log("ok-${i}");
|
||
`,
|
||
}),
|
||
);
|
||
const results = await Promise.all(promises);
|
||
for (let i = 0; i < results.length; i++) {
|
||
const r = results[i];
|
||
assert.equal(typeof r.exitCode, "number", `Execution ${i}: exitCode not a number`);
|
||
assert.equal(typeof r.stdout, "string", `Execution ${i}: stdout not a string`);
|
||
assert.equal(typeof r.stderr, "string", `Execution ${i}: stderr not a string`);
|
||
assert.equal(typeof r.timedOut, "boolean", `Execution ${i}: timedOut not a boolean`);
|
||
assert.equal(r.exitCode, 0, `Execution ${i} failed with stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes(`ok-${i}`), `Missing output for execution ${i}`);
|
||
}
|
||
});
|
||
|
||
test("node runtime accessible from executor shell", async () => {
|
||
// Use process.execPath rather than bare 'node' — snap/wrapper installs silently
|
||
// exit 0 with no output when the snap wrapper is re-invoked as a subprocess.
|
||
const r = await executor.execute({
|
||
language: "shell",
|
||
code: `"${process.execPath}" --version`,
|
||
});
|
||
assert.equal(r.exitCode, 0, `node not found in executor env, stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.trim().startsWith("v"), `Expected version string, got: ${r.stdout}`);
|
||
});
|
||
});
|
||
|
||
describe("Background Mode", () => {
|
||
test("background: true returns partial output with backgrounded flag", async () => {
|
||
const bgExecutor = new PolyglotExecutor({ runtimes });
|
||
const r = await bgExecutor.execute({
|
||
language: "javascript",
|
||
code: `console.log("started"); setInterval(() => {}, 1000);`,
|
||
timeout: 500,
|
||
background: true,
|
||
});
|
||
assert.equal(r.backgrounded, true, "Should be marked as backgrounded");
|
||
assert.equal(r.timedOut, true, "Background detach fires on timeout");
|
||
assert.equal(r.exitCode, 0, "Backgrounded processes return exitCode 0");
|
||
assert.ok(r.stdout.includes("started"), "Should capture output before detach");
|
||
bgExecutor.cleanupBackgrounded();
|
||
}, 10_000);
|
||
|
||
test("cleanupBackgrounded kills detached process", async () => {
|
||
const bgExecutor = new PolyglotExecutor({ runtimes });
|
||
const r = await bgExecutor.execute({
|
||
language: "javascript",
|
||
code: `process.stdout.write(String(process.pid)); setInterval(() => {}, 1000);`,
|
||
timeout: 500,
|
||
background: true,
|
||
});
|
||
const pid = parseInt(r.stdout.trim(), 10);
|
||
assert.ok(pid > 0, `Expected valid PID, got: "${r.stdout}"`);
|
||
|
||
let alive = false;
|
||
try { process.kill(pid, 0); alive = true; } catch { /* ESRCH */ }
|
||
assert.equal(alive, true, "Process should be alive before cleanup");
|
||
|
||
bgExecutor.cleanupBackgrounded();
|
||
await new Promise((r) => setTimeout(r, 300));
|
||
|
||
alive = false;
|
||
try { process.kill(pid, 0); alive = true; } catch { /* ESRCH */ }
|
||
assert.equal(alive, false, `Process ${pid} should be dead after cleanup`);
|
||
}, 10_000);
|
||
|
||
test("background: true keeps process alive when it writes after detach", async () => {
|
||
const bgExecutor = new PolyglotExecutor({ runtimes });
|
||
const r = await bgExecutor.execute({
|
||
language: "javascript",
|
||
code: `
|
||
process.stdout.write(String(process.pid));
|
||
// Write every 200ms after detach (timeout is 300ms)
|
||
const id = setInterval(() => {
|
||
process.stdout.write('alive\\n');
|
||
}, 200);
|
||
// Keep alive for 3 seconds total
|
||
setTimeout(() => { clearInterval(id); process.exit(0); }, 3000);
|
||
`,
|
||
timeout: 300,
|
||
background: true,
|
||
});
|
||
const pid = parseInt(r.stdout.trim(), 10);
|
||
assert.ok(pid > 0, `Expected valid PID, got: "${r.stdout}"`);
|
||
|
||
// Give the process time to write after detach
|
||
await new Promise((r) => setTimeout(r, 1000));
|
||
|
||
let alive = false;
|
||
try { process.kill(pid, 0); alive = true; } catch { /* ESRCH */ }
|
||
assert.equal(alive, true, `Process ${pid} should still be alive after detach (SIGPIPE bug?)`);
|
||
|
||
bgExecutor.cleanupBackgrounded();
|
||
}, 10_000);
|
||
});
|
||
|
||
describe("hardCapBytes Enforcement", () => {
|
||
test("kills process when combined output exceeds byte cap", async () => {
|
||
const cappedExecutor = new PolyglotExecutor({
|
||
runtimes,
|
||
hardCapBytes: 1024,
|
||
});
|
||
const r = await cappedExecutor.execute({
|
||
language: "javascript",
|
||
code: `for (let i = 0; i < 10000; i++) console.log("x".repeat(100));`,
|
||
timeout: 10_000,
|
||
});
|
||
assert.ok(r.stderr.includes("output capped at"), "Should indicate cap was hit");
|
||
assert.notEqual(r.exitCode, 0, "Process should be killed (non-zero exit)");
|
||
}, 15_000);
|
||
|
||
test("stderr contributes to byte cap", async () => {
|
||
const cappedExecutor = new PolyglotExecutor({
|
||
runtimes,
|
||
hardCapBytes: 1024,
|
||
});
|
||
const r = await cappedExecutor.execute({
|
||
language: "javascript",
|
||
code: `for (let i = 0; i < 10000; i++) console.error("e".repeat(100));`,
|
||
timeout: 10_000,
|
||
});
|
||
assert.ok(r.stderr.includes("output capped at"), "stderr should trigger cap");
|
||
}, 15_000);
|
||
});
|
||
|
||
describe("Windows Shell Support", () => {
|
||
test("shell runtime is always a non-empty string", async () => {
|
||
assert.ok(
|
||
typeof runtimes.shell === "string" && runtimes.shell.length > 0,
|
||
`shell should always be a non-empty string, got: ${runtimes.shell}`,
|
||
);
|
||
});
|
||
|
||
test("getAvailableLanguages always includes shell", async () => {
|
||
const { getAvailableLanguages } = await import("../src/runtime.js");
|
||
const langs = getAvailableLanguages(runtimes);
|
||
assert.ok(langs.includes("shell"), `shell should always be in available languages, got: ${langs}`);
|
||
});
|
||
|
||
test("buildCommand returns shell command array", async () => {
|
||
const cmd = buildCommand(runtimes, "shell", "/tmp/script.sh");
|
||
assert.ok(Array.isArray(cmd) && cmd.length > 0, `Expected non-empty array, got: ${cmd}`);
|
||
if (process.platform === "win32" && (cmd[0].toLowerCase().includes("bash") || cmd[0].toLowerCase().endsWith("\\sh.exe"))) {
|
||
// Windows + bash → `bash -c "source 'path'"` to dodge MSYS path mangling.
|
||
assert.equal(cmd.length, 3, `Expected [bash, -c, source ...], got: ${cmd}`);
|
||
assert.equal(cmd[1], "-c");
|
||
assert.ok(cmd[2].includes("/tmp/script.sh"), `Expected source clause to reference path, got: ${cmd[2]}`);
|
||
} else if (process.platform === "win32" && /powershell|pwsh/i.test(cmd[0])) {
|
||
assert.equal(cmd.length, 6, `Expected [powershell, -NoProfile, -ExecutionPolicy, Bypass, -File, path], got: ${cmd}`);
|
||
assert.deepEqual(cmd.slice(1, 5), ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
|
||
assert.equal(cmd[5], "/tmp/script.sh");
|
||
} else {
|
||
assert.equal(cmd.length, 2, `Expected [shell, path], got: ${cmd}`);
|
||
assert.equal(cmd[1], "/tmp/script.sh");
|
||
}
|
||
});
|
||
|
||
// --- Issue #384: hide Windows console + drop .sh extension for shell ---
|
||
|
||
test("buildSpawnOptions: windowsHide=true on Windows", async () => {
|
||
assert.equal(buildSpawnOptions("win32").windowsHide, true);
|
||
});
|
||
|
||
test("buildSpawnOptions: windowsHide=false on macOS/Linux", async () => {
|
||
assert.equal(buildSpawnOptions("darwin").windowsHide, false);
|
||
assert.equal(buildSpawnOptions("linux").windowsHide, false);
|
||
});
|
||
|
||
test("buildScriptFilename: POSIX shell on Windows has NO extension (avoid .sh file association)", async () => {
|
||
assert.equal(buildScriptFilename("shell", "win32"), "script");
|
||
assert.equal(buildScriptFilename("shell", "win32", "C:\\Program Files\\Git\\usr\\bin\\bash.exe"), "script");
|
||
assert.equal(buildScriptFilename("shell", "win32", "sh"), "script");
|
||
});
|
||
|
||
test("buildScriptFilename: cmd on Windows uses .cmd extension", async () => {
|
||
assert.equal(buildScriptFilename("shell", "win32", "cmd.exe"), "script.cmd");
|
||
assert.equal(buildScriptFilename("shell", "win32", "C:\\Windows\\System32\\cmd.exe"), "script.cmd");
|
||
});
|
||
|
||
test("buildScriptFilename: PowerShell on Windows uses .ps1 extension", async () => {
|
||
assert.equal(buildScriptFilename("shell", "win32", "powershell"), "script.ps1");
|
||
assert.equal(buildScriptFilename("shell", "win32", "pwsh"), "script.ps1");
|
||
assert.equal(
|
||
buildScriptFilename("shell", "win32", "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
|
||
"script.ps1",
|
||
);
|
||
assert.equal(
|
||
buildScriptFilename("shell", "win32", "C:\\Program Files\\PowerShell\\7\\pwsh.exe"),
|
||
"script.ps1",
|
||
);
|
||
});
|
||
|
||
test.runIf(process.platform === "win32")("PowerShell shell runtime executes generated script", async () => {
|
||
const powershellRuntimes: RuntimeMap = { ...runtimes, shell: "powershell" };
|
||
const powershellExecutor = new PolyglotExecutor({ runtimes: powershellRuntimes });
|
||
const r = await powershellExecutor.execute({
|
||
language: "shell",
|
||
code: 'Write-Output "POWERSHELL_EXECUTOR_OK"',
|
||
timeout: 10_000,
|
||
});
|
||
|
||
assert.equal(r.exitCode, 0, `stderr: ${r.stderr}`);
|
||
assert.ok(r.stdout.includes("POWERSHELL_EXECUTOR_OK"), `stdout: ${r.stdout}`);
|
||
});
|
||
|
||
test("buildScriptFilename: shell on Unix keeps .sh extension", async () => {
|
||
assert.equal(buildScriptFilename("shell", "darwin"), "script.sh");
|
||
assert.equal(buildScriptFilename("shell", "linux"), "script.sh");
|
||
assert.equal(buildScriptFilename("shell", "linux", "pwsh"), "script.sh");
|
||
assert.equal(buildScriptFilename("shell", "darwin", "powershell"), "script.sh");
|
||
});
|
||
|
||
test("buildScriptFilename: non-shell languages keep their extension on Windows", async () => {
|
||
assert.equal(buildScriptFilename("python", "win32"), "script.py");
|
||
assert.equal(buildScriptFilename("javascript", "win32"), "script.js");
|
||
assert.equal(buildScriptFilename("typescript", "win32"), "script.ts");
|
||
assert.equal(buildScriptFilename("ruby", "win32"), "script.rb");
|
||
assert.equal(buildScriptFilename("go", "win32"), "script.go");
|
||
assert.equal(buildScriptFilename("rust", "win32"), "script.rs");
|
||
assert.equal(buildScriptFilename("csharp", "win32"), "script.csx");
|
||
});
|
||
|
||
test("buildScriptFilename: non-shell languages keep their extension on Unix", async () => {
|
||
assert.equal(buildScriptFilename("python", "linux"), "script.py");
|
||
assert.equal(buildScriptFilename("javascript", "darwin"), "script.js");
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// Sibling MCP discovery — issue #559
|
||
// /ctx-upgrade must terminate previous-version MCP servers so they don't
|
||
// pile up across upgrades. discoverSiblingMcpPids enumerates node procs
|
||
// whose argv contains the plugin start.mjs path, excluding self + parent.
|
||
// ─────────────────────────────────────────────────────────
|
||
describe("discoverSiblingMcpPids (#559)", () => {
|
||
test("parses pgrep output, filters own pid + ppid", async () => {
|
||
const { discoverSiblingMcpPids } = await import("../src/util/sibling-mcp.js");
|
||
const ownPid = 11111;
|
||
const ownPpid = 22222;
|
||
// Mock pgrep returning four candidate PIDs, two of which are self/parent.
|
||
const fakeRunner = (_cmd: string, _args: readonly string[]) =>
|
||
`${ownPid}\n${ownPpid}\n33333\n44444\n`;
|
||
const pids = discoverSiblingMcpPids({
|
||
ownPid,
|
||
ownPpid,
|
||
platform: "linux",
|
||
runCommand: fakeRunner,
|
||
});
|
||
assert.deepEqual(pids.sort((a, b) => a - b), [33333, 44444]);
|
||
});
|
||
|
||
test("returns empty array when discovery tool is absent (never throws)", async () => {
|
||
const { discoverSiblingMcpPids } = await import("../src/util/sibling-mcp.js");
|
||
const pids = discoverSiblingMcpPids({
|
||
ownPid: 1,
|
||
ownPpid: 0,
|
||
platform: "linux",
|
||
runCommand: () => { throw new Error("ENOENT: pgrep not found"); },
|
||
});
|
||
assert.deepEqual(pids, []);
|
||
});
|
||
|
||
test("Windows branch parses CIM/WMI ProcessId column", async () => {
|
||
const { discoverSiblingMcpPids } = await import("../src/util/sibling-mcp.js");
|
||
// PowerShell `Select-Object -ExpandProperty ProcessId` produces newline
|
||
// separated integers. Some shells echo a header row — filter must skip it.
|
||
const fakePs = (_cmd: string, _args: readonly string[]) =>
|
||
"ProcessId\n----------\n55555\n66666\n";
|
||
const pids = discoverSiblingMcpPids({
|
||
ownPid: 1,
|
||
ownPpid: 2,
|
||
platform: "win32",
|
||
runCommand: fakePs,
|
||
});
|
||
assert.deepEqual(pids.sort((a, b) => a - b), [55555, 66666]);
|
||
});
|
||
|
||
test("ignores blank lines and non-numeric noise", async () => {
|
||
const { discoverSiblingMcpPids } = await import("../src/util/sibling-mcp.js");
|
||
const fakeRunner = (_cmd: string, _args: readonly string[]) =>
|
||
"\n \nnot-a-pid\n77777\n 88888 \n";
|
||
const pids = discoverSiblingMcpPids({
|
||
ownPid: 1,
|
||
ownPpid: 2,
|
||
platform: "linux",
|
||
runCommand: fakeRunner,
|
||
});
|
||
assert.deepEqual(pids.sort((a, b) => a - b), [77777, 88888]);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// killSiblingMcpServers — issue #559
|
||
// SIGTERM with timeout-based escalation to SIGKILL. Reports per-pid
|
||
// outcome so cli.ts can surface a human-readable summary.
|
||
// ─────────────────────────────────────────────────────────
|
||
describe("killSiblingMcpServers (#559)", () => {
|
||
test("escalates to SIGKILL after timeout when SIGTERM is ignored", async () => {
|
||
const { killSiblingMcpServers } = await import("../src/util/sibling-mcp.js");
|
||
// Fake liveness map: pid -> array of remaining alive checks before death.
|
||
// pid 1: dies after 1 alive check (responds to SIGTERM).
|
||
// pid 2: never dies via SIGTERM, only after SIGKILL is sent.
|
||
const aliveCheckCounts = new Map<number, number>([[1, 1], [2, 999]]);
|
||
const sigKilled = new Set<number>();
|
||
const sigTermSent: number[] = [];
|
||
const isAlive = (pid: number): boolean => {
|
||
// After SIGKILL, the process is dead.
|
||
if (sigKilled.has(pid)) return false;
|
||
const remaining = aliveCheckCounts.get(pid) ?? 0;
|
||
if (remaining <= 0) return false;
|
||
aliveCheckCounts.set(pid, remaining - 1);
|
||
return true;
|
||
};
|
||
const sendSignal = (pid: number, sig: NodeJS.Signals): void => {
|
||
if (sig === "SIGKILL") sigKilled.add(pid);
|
||
if (sig === "SIGTERM") sigTermSent.push(pid);
|
||
};
|
||
const report = await killSiblingMcpServers({
|
||
pids: [1, 2],
|
||
timeoutMs: 50,
|
||
pollIntervalMs: 10,
|
||
isAlive,
|
||
sendSignal,
|
||
});
|
||
assert.deepEqual(sigTermSent.sort((a, b) => a - b), [1, 2]);
|
||
assert.equal(report.terminatedBySigterm, 1);
|
||
assert.equal(report.terminatedBySigkill, 1);
|
||
assert.equal(report.totalKilled, 2);
|
||
});
|
||
|
||
test("returns zero counts and never throws on empty input", async () => {
|
||
const { killSiblingMcpServers } = await import("../src/util/sibling-mcp.js");
|
||
const report = await killSiblingMcpServers({
|
||
pids: [],
|
||
timeoutMs: 10,
|
||
pollIntervalMs: 5,
|
||
isAlive: () => false,
|
||
sendSignal: () => { throw new Error("should not be called"); },
|
||
});
|
||
assert.equal(report.totalKilled, 0);
|
||
assert.equal(report.terminatedBySigterm, 0);
|
||
assert.equal(report.terminatedBySigkill, 0);
|
||
});
|
||
|
||
test("swallows ESRCH from sendSignal (process already gone)", async () => {
|
||
const { killSiblingMcpServers } = await import("../src/util/sibling-mcp.js");
|
||
const report = await killSiblingMcpServers({
|
||
pids: [99999999],
|
||
timeoutMs: 20,
|
||
pollIntervalMs: 5,
|
||
isAlive: () => false,
|
||
sendSignal: () => {
|
||
const e: NodeJS.ErrnoException = new Error("ESRCH") as NodeJS.ErrnoException;
|
||
e.code = "ESRCH";
|
||
throw e;
|
||
},
|
||
});
|
||
// Process was already dead — counts as 0 since we never observed it alive.
|
||
assert.equal(report.totalKilled, 0);
|
||
});
|
||
});
|