openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
515 lines (514 loc) • 17.8 kB
JavaScript
import { c as normalizeOptionalLowercaseString } from "./string-coerce-CIXf7egm.js";
import { t as killProcessTree } from "./kill-tree-CR2oLt9D.js";
import { t as createAbortError } from "./abort-signal-D2k14JsD.js";
import { s as sanitizeHostExecEnv } from "./host-env-security-BAvlDaJf.js";
import { i as logWarn, t as logDebug } from "./logger-DwECwNVZ.js";
import { n as resolveStdioMcpServerLaunchConfig, t as describeStdioMcpServerLaunchConfig } from "./mcp-stdio-BaVNPJDo.js";
import { a as setPluginToolMeta } from "./tool-metadata-B5aqo73s.js";
import { a as resolveWindowsSpawnProgram, r as materializeWindowsSpawnProgram } from "./windows-spawn-oyE-2b7Z.js";
import { t as createPendingRequestRegistry } from "./pending-request-registry-B-sdjY_C.js";
import { n as loadEnabledBundleLspConfig } from "./bundle-lsp-TidsbhfG.js";
import { spawn } from "node:child_process";
//#region src/agents/agent-bundle-lsp-process.ts
/** Spawns bundled LSP server processes with sanitized environment and platform handling. */
const defaultLspSpawnDependencies = {
spawn,
sanitizeHostExecEnv,
resolveWindowsSpawnProgram,
materializeWindowsSpawnProgram
};
function spawnLspServerProcess(config, dependencies = defaultLspSpawnDependencies) {
const mergedEnv = dependencies.sanitizeHostExecEnv({
baseEnv: process.env,
overrides: config.env ?? null
});
const program = dependencies.resolveWindowsSpawnProgram({
command: config.command,
env: mergedEnv,
allowShellFallback: true
});
const invocation = dependencies.materializeWindowsSpawnProgram(program, config.args ?? []);
return dependencies.spawn(invocation.command, invocation.argv, {
stdio: [
"pipe",
"pipe",
"pipe"
],
env: mergedEnv,
cwd: config.cwd,
detached: process.platform !== "win32",
windowsHide: invocation.windowsHide ?? process.platform === "win32",
shell: invocation.shell
});
}
//#endregion
//#region src/agents/embedded-agent-lsp.ts
/** Resolve enabled embedded-agent LSP servers and diagnostics. */
function loadEmbeddedAgentLspConfig(params) {
const bundleLsp = loadEnabledBundleLspConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry
});
return {
lspServers: { ...bundleLsp.config.lspServers },
diagnostics: bundleLsp.diagnostics
};
}
//#endregion
//#region src/agents/agent-bundle-lsp-dependencies.ts
const defaultBundleLspRuntimeDependencies = {
loadLspConfig: loadEmbeddedAgentLspConfig,
spawnServerProcess: spawnLspServerProcess,
killProcessTree
};
//#endregion
//#region src/agents/agent-bundle-lsp-runtime.ts
const LSP_SHUTDOWN_GRACE_MS = 500;
const LSP_PROCESS_TREE_KILL_GRACE_MS = 1e3;
const activeBundleLspSessions = /* @__PURE__ */ new Set();
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, Math.max(1, ms)).unref?.();
});
}
function createLspSession(serverName, child, killProcessTree) {
return {
serverName,
process: child,
requestId: 0,
pendingRequests: createPendingRequestRegistry(),
buffer: Buffer.alloc(0),
initialized: false,
capabilities: {},
disposed: false,
killProcessTree
};
}
function registerActiveLspSession(session) {
activeBundleLspSessions.add(session);
}
function rememberLspFailure(session, error) {
session.failure ??= error;
}
function failLspSession(session, error) {
rememberLspFailure(session, error);
session.pendingRequests.rejectAll(session.failure ?? error);
}
function lspProcessExitError(session, code, signal) {
return /* @__PURE__ */ new Error(`LSP server "${session.serverName}" exited (${signal ?? code ?? "unknown"})`);
}
function attachLspProcessHandlers(session) {
session.process.on("error", (error) => {
failLspSession(session, error);
});
session.process.on("exit", (code, signal) => {
rememberLspFailure(session, lspProcessExitError(session, code, signal));
});
session.process.on("close", (code, signal) => {
failLspSession(session, lspProcessExitError(session, code, signal));
});
session.process.stdout?.on("data", (chunk) => handleIncomingData(session, chunk));
session.process.stdout?.on("error", (error) => {
failLspSession(session, error);
});
session.process.stdin?.on("error", (error) => {
failLspSession(session, error);
});
session.process.stderr?.setEncoding("utf-8");
session.process.stderr?.on("data", (chunk) => {
for (const line of chunk.split(/\r?\n/).filter(Boolean)) logDebug(`bundle-lsp:${session.serverName}: ${line.trim()}`);
});
session.process.stderr?.on("error", (error) => {
logWarn(`bundle-lsp:${session.serverName}: stderr failed: ${String(error)}`);
});
}
function encodeLspMessage(body) {
const json = JSON.stringify(body);
return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`;
}
const LSP_HEADER_SEPARATOR = Buffer.from("\r\n\r\n", "ascii");
const MAX_LSP_HEADER_BYTES = 8192;
const MAX_LSP_BODY_BYTES = 67108864;
const LSP_BODY_DECODER = new TextDecoder("utf-8", {
fatal: true,
ignoreBOM: true
});
var LspFramingError = class extends Error {
constructor(..._args) {
super(..._args);
this.name = "LspFramingError";
}
};
function framingError(messages, detail) {
return {
ok: false,
messages,
error: new LspFramingError(`LSP framing error: ${detail}`)
};
}
function parseContentLength(header) {
const values = [];
for (const line of header.split("\r\n")) {
const separator = line.indexOf(":");
if (separator === -1) return new LspFramingError("LSP framing error: header line must contain a colon");
if (line.slice(0, separator).trim().toLowerCase() === "content-length") values.push(line.slice(separator + 1).trim());
}
if (values.length !== 1) return new LspFramingError(`LSP framing error: expected exactly one Content-Length header, received ${values.length}`);
const value = values[0];
if (value === void 0 || !/^[0-9]+$/.test(value)) return new LspFramingError("LSP framing error: Content-Length must be decimal digits");
const length = Number(value);
if (!Number.isSafeInteger(length) || length <= 0) return new LspFramingError("LSP framing error: Content-Length must be a positive safe integer");
if (length > MAX_LSP_BODY_BYTES) return new LspFramingError(`LSP framing error: Content-Length exceeds ${MAX_LSP_BODY_BYTES} bytes`);
return length;
}
function parseLspMessages(buffer) {
const messages = [];
let remaining = buffer;
while (true) {
const headerEnd = remaining.indexOf(LSP_HEADER_SEPARATOR);
if (headerEnd === -1) {
const maxIncompleteHeaderBytes = MAX_LSP_HEADER_BYTES + LSP_HEADER_SEPARATOR.length - 1;
return remaining.length > maxIncompleteHeaderBytes ? framingError(messages, `header exceeds ${MAX_LSP_HEADER_BYTES} bytes`) : {
ok: true,
messages,
remaining
};
}
if (headerEnd > MAX_LSP_HEADER_BYTES) return framingError(messages, `header exceeds ${MAX_LSP_HEADER_BYTES} bytes`);
const contentLength = parseContentLength(remaining.subarray(0, headerEnd).toString("ascii"));
if (contentLength instanceof LspFramingError) return {
ok: false,
messages,
error: contentLength
};
const bodyStart = headerEnd + LSP_HEADER_SEPARATOR.length;
const bodyEnd = bodyStart + contentLength;
if (remaining.length < bodyEnd) return {
ok: true,
messages,
remaining
};
let body;
try {
body = LSP_BODY_DECODER.decode(remaining.subarray(bodyStart, bodyEnd));
} catch {
return framingError(messages, "body is not valid UTF-8");
}
try {
messages.push(JSON.parse(body));
} catch (error) {
return framingError(messages, `body is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
remaining = remaining.subarray(bodyEnd);
}
}
function lspAbortError(signal) {
return signal?.reason instanceof Error ? signal.reason : createAbortError("LSP request aborted", { cause: signal?.reason });
}
function lspSessionDisposedError() {
return /* @__PURE__ */ new Error("LSP session disposed");
}
function sendRequest(session, method, params, signal) {
if (session.disposed && method !== "shutdown") return Promise.reject(lspSessionDisposedError());
if (session.failure) return Promise.reject(session.failure);
if (signal?.aborted) return Promise.reject(lspAbortError(signal));
const id = ++session.requestId;
const onAbort = () => {
const aborted = session.pendingRequests.take(id);
if (!aborted) return;
try {
session.process.stdin?.write(encodeLspMessage({
jsonrpc: "2.0",
method: "$/cancelRequest",
params: { id }
}), "utf-8");
} catch {}
aborted.reject(lspAbortError(signal));
};
const pending = session.pendingRequests.add(id, {
value: void 0,
timeoutMs: 1e4,
timeoutError: () => /* @__PURE__ */ new Error(`LSP request ${method} timed out`),
dispose: () => signal?.removeEventListener("abort", onAbort)
});
if (!pending) return Promise.reject(/* @__PURE__ */ new Error(`LSP request id collision: ${id}`));
signal?.addEventListener("abort", onAbort, { once: true });
const message = {
jsonrpc: "2.0",
id,
method,
params
};
try {
const encoded = encodeLspMessage(message);
session.process.stdin?.write(encoded, "utf-8");
} catch (error) {
pending.reject(error);
}
return pending.promise;
}
function handleIncomingData(session, chunk) {
session.buffer = Buffer.concat([session.buffer, typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk]);
const parsed = parseLspMessages(session.buffer);
session.buffer = parsed.ok ? parsed.remaining.length === 0 ? Buffer.alloc(0) : Buffer.from(parsed.remaining) : Buffer.alloc(0);
for (const msg of parsed.messages) {
if (typeof msg !== "object" || msg === null) continue;
const record = msg;
if ("id" in record && typeof record.id === "number") {
const pending = session.pendingRequests.take(record.id);
if (pending) {
if ("error" in record) pending.reject(new Error(JSON.stringify(record.error)));
else pending.resolve(record.result);
}
}
if ("method" in record && !("id" in record)) logDebug(`bundle-lsp:${session.serverName}: notification ${String(record.method)}`);
}
if (!parsed.ok) {
failLspSession(session, parsed.error);
terminateLspProcessTree(session);
}
}
async function initializeSession(session) {
const result = await sendRequest(session, "initialize", {
processId: process.pid,
rootUri: null,
capabilities: { textDocument: {
hover: { contentFormat: ["plaintext", "markdown"] },
completion: { completionItem: { snippetSupport: false } },
definition: {},
references: {}
} }
});
session.process.stdin?.write(encodeLspMessage({
jsonrpc: "2.0",
method: "initialized",
params: {}
}), "utf-8");
session.initialized = true;
return result?.capabilities ?? {};
}
function hasLspProcessExited(child) {
return child.exitCode !== null || child.signalCode !== null;
}
function terminateLspProcessTree(session) {
const pid = session.process.pid;
if (pid && !hasLspProcessExited(session.process)) {
session.killProcessTree(pid, {
graceMs: LSP_PROCESS_TREE_KILL_GRACE_MS,
detached: true
});
return;
}
if (!hasLspProcessExited(session.process)) session.process.kill("SIGTERM");
}
async function disposeSession(session) {
if (session.disposed) return;
session.disposed = true;
session.pendingRequests.rejectAll(lspSessionDisposedError());
activeBundleLspSessions.delete(session);
if (session.initialized) try {
const shutdown = sendRequest(session, "shutdown").catch(() => void 0);
await Promise.race([shutdown, delay(LSP_SHUTDOWN_GRACE_MS)]);
session.process.stdin?.write(encodeLspMessage({
jsonrpc: "2.0",
method: "exit",
params: null
}), "utf-8");
} catch {}
session.pendingRequests.rejectAll(lspSessionDisposedError());
terminateLspProcessTree(session);
}
async function disposeSessions(sessions) {
await Promise.allSettled(Array.from(sessions, (session) => disposeSession(session)));
}
function createLspPositionTool(params) {
return {
name: params.toolName,
label: params.label,
description: params.description,
parameters: {
type: "object",
properties: {
uri: {
type: "string",
description: "File URI (file:///path/to/file)"
},
line: {
type: "number",
description: "Zero-based line number"
},
character: {
type: "number",
description: "Zero-based character offset"
}
},
required: [
"uri",
"line",
"character"
]
},
execute: async (_toolCallId, input, signal) => {
const position = input;
const result = await sendRequest(params.session, params.method, {
textDocument: { uri: position.uri },
position: {
line: position.line,
character: position.character
}
}, signal);
return formatLspResult(params.session.serverName, params.resultLabel, result);
}
};
}
function buildLspTools(session) {
const tools = [];
const caps = session.capabilities;
const serverLabel = session.serverName;
if (caps.hoverProvider) tools.push(createLspPositionTool({
session,
toolName: `lsp_hover_${serverLabel}`,
label: `LSP Hover (${serverLabel})`,
description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`,
method: "textDocument/hover",
resultLabel: "hover"
}));
if (caps.definitionProvider) tools.push(createLspPositionTool({
session,
toolName: `lsp_definition_${serverLabel}`,
label: `LSP Go to Definition (${serverLabel})`,
description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`,
method: "textDocument/definition",
resultLabel: "definition"
}));
if (caps.referencesProvider) tools.push({
name: `lsp_references_${serverLabel}`,
label: `LSP Find References (${serverLabel})`,
description: `Find all references to a symbol at a position in a file via the ${serverLabel} language server.`,
parameters: {
type: "object",
properties: {
uri: {
type: "string",
description: "File URI (file:///path/to/file)"
},
line: {
type: "number",
description: "Zero-based line number"
},
character: {
type: "number",
description: "Zero-based character offset"
},
includeDeclaration: {
type: "boolean",
description: "Include the declaration in results"
}
},
required: [
"uri",
"line",
"character"
]
},
execute: async (_toolCallId, input, signal) => {
const params = input;
const result = await sendRequest(session, "textDocument/references", {
textDocument: { uri: params.uri },
position: {
line: params.line,
character: params.character
},
context: { includeDeclaration: params.includeDeclaration ?? true }
}, signal);
return formatLspResult(serverLabel, "references", result);
}
});
return tools;
}
function formatLspResult(serverName, method, result) {
return {
content: [{
type: "text",
text: result !== null && result !== void 0 ? JSON.stringify(result, null, 2) : `No ${method} result from ${serverName}`
}],
details: {
lspServer: serverName,
lspMethod: method
}
};
}
async function createBundleLspToolRuntime(params) {
const dependencies = params.dependencies ?? defaultBundleLspRuntimeDependencies;
const loaded = dependencies.loadLspConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
manifestRegistry: params.manifestRegistry
});
for (const diagnostic of loaded.diagnostics) logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`);
if (Object.keys(loaded.lspServers).length === 0) return {
tools: [],
sessions: [],
dispose: async () => {}
};
const reservedNames = new Set(Array.from(params.reservedToolNames ?? [], (name) => normalizeOptionalLowercaseString(name)).filter(Boolean));
const sessions = [];
const tools = [];
try {
for (const [serverName, rawServer] of Object.entries(loaded.lspServers)) {
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
if (!launch.ok) {
logWarn(`bundle-lsp: skipped server "${serverName}" because ${launch.reason}.`);
continue;
}
const launchConfig = launch.config;
let session;
try {
session = createLspSession(serverName, dependencies.spawnServerProcess(launchConfig), dependencies.killProcessTree);
registerActiveLspSession(session);
attachLspProcessHandlers(session);
const capabilities = await initializeSession(session);
session.capabilities = capabilities;
sessions.push(session);
const serverTools = buildLspTools(session);
for (const tool of serverTools) {
const normalizedName = normalizeOptionalLowercaseString(tool.name);
if (!normalizedName) continue;
if (reservedNames.has(normalizedName)) {
logWarn(`bundle-lsp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`);
continue;
}
reservedNames.add(normalizedName);
setPluginToolMeta(tool, {
pluginId: "bundle-lsp",
optional: false
});
tools.push(tool);
}
logDebug(`bundle-lsp: started "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}) with ${serverTools.length} tools`);
} catch (error) {
if (session) await disposeSession(session);
logWarn(`bundle-lsp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`);
}
}
return {
tools,
sessions: sessions.map((s) => ({
serverName: s.serverName,
capabilities: s.capabilities
})),
dispose: async () => {
await disposeSessions(sessions);
}
};
} catch (error) {
await disposeSessions(sessions);
throw error;
}
}
async function disposeAllBundleLspRuntimes() {
await disposeSessions(activeBundleLspSessions);
}
//#endregion
export { disposeAllBundleLspRuntimes as n, createBundleLspToolRuntime as t };