scripts / runtime.cjs
scripts / runtime.cjs
const { spawn } = require("node:child_process");
const { readFileSync, existsSync, mkdirSync, writeFileSync, openSync, closeSync, unlinkSync } = require("node:fs");
const { join, resolve } = require("node:path");
const { homedir } = require("node:os");
const { createHash } = require("node:crypto");
// Both the source scripts/ directory and LM Studio's bundled .lmstudio/ entry
// are immediate children of the plugin root. No developer-specific path is used.
const pluginRoot = resolve(__dirname, "..");
function locations() {
const requirementFile = join(pluginRoot, "python", "requirements.txt");
const digest = createHash("sha256").update(readFileSync(requirementFile)).digest("hex").slice(0, 16);
const home = resolve(process.env.WEB_SECURITY_PLUGIN_HOME || join(homedir(), ".lmstudio", "web-security-mcp"));
const environment = join(home, "python-" + digest);
return {
home, environment, requirementFile,
python: join(environment, process.platform === "win32" ? "Scripts" : "bin", process.platform === "win32" ? "python.exe" : "python"),
marker: join(environment, "web-security-ready.json"),
entry: join(pluginRoot, "python", "bridge_entry.py"),
};
}
function run(command, args, options = {}) {
return new Promise((resolveRun, reject) => {
if (options.signal?.aborted) return reject(new Error("Cancelled"));
const child = spawn(command, args, {
cwd: pluginRoot, env: { ...process.env, ...options.env }, windowsHide: true,
shell: false, stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "", stderr = "", bytes = 0, settled = false;
const finish = (error, result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
error ? reject(error) : resolveRun(result);
};
const abort = () => { child.kill(); finish(new Error("Cancelled")); };
const timer = setTimeout(() => { child.kill(); finish(new Error("Python process timed out")); }, options.timeout || 120000);
options.signal?.addEventListener("abort", abort, { once: true });
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
const receive = (text, stream) => {
bytes += Buffer.byteLength(text);
if (bytes > (options.maxBytes || 16 * 1024 * 1024)) { child.kill(); finish(new Error("Python output exceeded limit")); return; }
if (stream === "stdout") stdout += text; else stderr += text;
if (options.onOutput) options.onOutput(text);
};
child.stdout.on("data", (text) => receive(text, "stdout"));
child.stderr.on("data", (text) => receive(text, "stderr"));
child.on("error", (error) => finish(error));
child.stdin.on("error", (error) => { if (error.code !== "EPIPE") finish(error); });
child.on("close", (code) => {
if (code !== 0 && !options.allowFailure) finish(new Error(`Python exited with code ${code}: ${stderr.slice(-2000)}`));
else finish(null, { code, stdout, stderr });
});
child.stdin.end(options.input || "");
});
}
async function findPython() {
const override = process.env.WEB_SECURITY_MCP_PYTHON;
const candidates = override ? [[override, []]] : process.platform === "win32"
? [["py", ["-3"]], ["python", []], ["python3", []]] : [["python3", []], ["python", []]];
for (const [command, prefix] of candidates) {
try {
const probe = await run(command, [...prefix, "-c", "import sys; assert sys.version_info >= (3,10); print(sys.executable)"], { timeout: 15000 });
if (probe.stdout.trim()) return { command, prefix };
} catch { /* Try the next executable without shell interpolation. */ }
}
throw new Error("Python 3.10以上が見つかりません。Pythonをインストール後、このプラグインのフォルダで npm run setup を実行してください。Windows/Python 3.12で検証済みです。");
}
async function setup() {
const paths = locations();
if (existsSync(paths.python) && existsSync(paths.marker)) return paths.python;
const base = await findPython();
mkdirSync(paths.home, { recursive: true });
const lock = join(paths.home, "setup.lock");
let descriptor;
try { descriptor = openSync(lock, "wx"); }
catch { throw new Error("Python環境の準備が別プロセスで実行中です。完了を待って npm run setup を再実行してください。中断後も続く場合はREADMEの復旧手順を参照してください。"); }
try {
const output = (text) => process.stderr.write(text);
await run(base.command, [...base.prefix, "-m", "venv", paths.environment], { timeout: 120000, onOutput: output });
await run(paths.python, ["-m", "pip", "install", "--disable-pip-version-check", "-r", paths.requirementFile], { timeout: 600000, onOutput: output });
await run(paths.python, ["-c", "import mcp,httpx,bs4,lxml"], { timeout: 20000 });
writeFileSync(paths.marker, JSON.stringify({ dependencies: readFileSync(paths.requirementFile, "utf8"), created_at: new Date().toISOString() }) + "\n");
return paths.python;
} finally {
closeSync(descriptor);
unlinkSync(lock);
}
}
async function invoke(toolName, args, options) {
if (!["scan_url", "explain_capabilities"].includes(toolName)) throw new Error("Unsupported tool");
const paths = locations();
const python = options.python || paths.python;
if (!options.python && (!existsSync(paths.python) || !existsSync(paths.marker))) {
throw new Error("専用Python環境が未準備です。プラグインのフォルダで npm run setup を実行するか、設定のPython executableに依存導入済みPythonを指定してください。詳しくはREADMEを参照してください。");
}
const result = await run(python, [paths.entry], {
input: JSON.stringify({ tool: toolName, arguments: args }), signal: options.signal, allowFailure: true,
env: { PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", PYTHONDONTWRITEBYTECODE: "1", WEB_SECURITY_MCP_REPORT_DIR: options.reportDirectory },
});
let decoded;
try { decoded = JSON.parse(result.stdout); }
catch { throw new Error("Pythonから正常なJSON応答を取得できませんでした。必要な依存とPython設定を確認してください。" + result.stderr.slice(-1000)); }
if (result.code !== 0) throw new Error(decoded.error || "Python scan failed");
// Never shorten, summarize or discard observations before passing to LM Studio.
return result.stdout.trim();
}
module.exports = { locations, run, setup, invoke };
const { spawn } = require("node:child_process");
const { readFileSync, existsSync, mkdirSync, writeFileSync, openSync, closeSync, unlinkSync } = require("node:fs");
const { join, resolve } = require("node:path");
const { homedir } = require("node:os");
const { createHash } = require("node:crypto");
// Both the source scripts/ directory and LM Studio's bundled .lmstudio/ entry
// are immediate children of the plugin root. No developer-specific path is used.
const pluginRoot = resolve(__dirname, "..");
function locations() {
const requirementFile = join(pluginRoot, "python", "requirements.txt");
const digest = createHash("sha256").update(readFileSync(requirementFile)).digest("hex").slice(0, 16);
const home = resolve(process.env.WEB_SECURITY_PLUGIN_HOME || join(homedir(), ".lmstudio", "web-security-mcp"));
const environment = join(home, "python-" + digest);
return {
home, environment, requirementFile,
python: join(environment, process.platform === "win32" ? "Scripts" : "bin", process.platform === "win32" ? "python.exe" : "python"),
marker: join(environment, "web-security-ready.json"),
entry: join(pluginRoot, "python", "bridge_entry.py"),
};
}
function run(command, args, options = {}) {
return new Promise((resolveRun, reject) => {
if (options.signal?.aborted) return reject(new Error("Cancelled"));
const child = spawn(command, args, {
cwd: pluginRoot, env: { ...process.env, ...options.env }, windowsHide: true,
shell: false, stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "", stderr = "", bytes = 0, settled = false;
const finish = (error, result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
error ? reject(error) : resolveRun(result);
};
const abort = () => { child.kill(); finish(new Error("Cancelled")); };
const timer = setTimeout(() => { child.kill(); finish(new Error("Python process timed out")); }, options.timeout || 120000);
options.signal?.addEventListener("abort", abort, { once: true });
child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8");
const receive = (text, stream) => {
bytes += Buffer.byteLength(text);
if (bytes > (options.maxBytes || 16 * 1024 * 1024)) { child.kill(); finish(new Error("Python output exceeded limit")); return; }
if (stream === "stdout") stdout += text; else stderr += text;
if (options.onOutput) options.onOutput(text);
};
child.stdout.on("data", (text) => receive(text, "stdout"));
child.stderr.on("data", (text) => receive(text, "stderr"));
child.on("error", (error) => finish(error));
child.stdin.on("error", (error) => { if (error.code !== "EPIPE") finish(error); });
child.on("close", (code) => {
if (code !== 0 && !options.allowFailure) finish(new Error(`Python exited with code ${code}: ${stderr.slice(-2000)}`));
else finish(null, { code, stdout, stderr });
});
child.stdin.end(options.input || "");
});
}
async function findPython() {
const override = process.env.WEB_SECURITY_MCP_PYTHON;
const candidates = override ? [[override, []]] : process.platform === "win32"
? [["py", ["-3"]], ["python", []], ["python3", []]] : [["python3", []], ["python", []]];
for (const [command, prefix] of candidates) {
try {
const probe = await run(command, [...prefix, "-c", "import sys; assert sys.version_info >= (3,10); print(sys.executable)"], { timeout: 15000 });
if (probe.stdout.trim()) return { command, prefix };
} catch { /* Try the next executable without shell interpolation. */ }
}
throw new Error("Python 3.10以上が見つかりません。Pythonをインストール後、このプラグインのフォルダで npm run setup を実行してください。Windows/Python 3.12で検証済みです。");
}
async function setup() {
const paths = locations();
if (existsSync(paths.python) && existsSync(paths.marker)) return paths.python;
const base = await findPython();
mkdirSync(paths.home, { recursive: true });
const lock = join(paths.home, "setup.lock");
let descriptor;
try { descriptor = openSync(lock, "wx"); }
catch { throw new Error("Python環境の準備が別プロセスで実行中です。完了を待って npm run setup を再実行してください。中断後も続く場合はREADMEの復旧手順を参照してください。"); }
try {
const output = (text) => process.stderr.write(text);
await run(base.command, [...base.prefix, "-m", "venv", paths.environment], { timeout: 120000, onOutput: output });
await run(paths.python, ["-m", "pip", "install", "--disable-pip-version-check", "-r", paths.requirementFile], { timeout: 600000, onOutput: output });
await run(paths.python, ["-c", "import mcp,httpx,bs4,lxml"], { timeout: 20000 });
writeFileSync(paths.marker, JSON.stringify({ dependencies: readFileSync(paths.requirementFile, "utf8"), created_at: new Date().toISOString() }) + "\n");
return paths.python;
} finally {
closeSync(descriptor);
unlinkSync(lock);
}
}
async function invoke(toolName, args, options) {
if (!["scan_url", "explain_capabilities"].includes(toolName)) throw new Error("Unsupported tool");
const paths = locations();
const python = options.python || paths.python;
if (!options.python && (!existsSync(paths.python) || !existsSync(paths.marker))) {
throw new Error("専用Python環境が未準備です。プラグインのフォルダで npm run setup を実行するか、設定のPython executableに依存導入済みPythonを指定してください。詳しくはREADMEを参照してください。");
}
const result = await run(python, [paths.entry], {
input: JSON.stringify({ tool: toolName, arguments: args }), signal: options.signal, allowFailure: true,
env: { PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", PYTHONDONTWRITEBYTECODE: "1", WEB_SECURITY_MCP_REPORT_DIR: options.reportDirectory },
});
let decoded;
try { decoded = JSON.parse(result.stdout); }
catch { throw new Error("Pythonから正常なJSON応答を取得できませんでした。必要な依存とPython設定を確認してください。" + result.stderr.slice(-1000)); }
if (result.code !== 0) throw new Error(decoded.error || "Python scan failed");
// Never shorten, summarize or discard observations before passing to LM Studio.
return result.stdout.trim();
}
module.exports = { locations, run, setup, invoke };