UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

468 lines (467 loc) • 17.2 kB
import { c as normalizeOptionalLowercaseString } from "./string-coerce-1VuiLaaR.mjs"; import { d as toErrorObject } from "./error-coercion-W9BtigRm.mjs"; import { i as createAbortError } from "./src-D1BHGNTj.mjs"; import "./abort-signal-QqAmLWTl.mjs"; import "./errors-BvOkmQM5.mjs"; import { s as sanitizeHostExecEnv } from "./host-env-security-DsfhifNr.mjs"; import { i as normalizeReservedToolNames } from "./agent-bundle-mcp-names-CFVETBL6.mjs"; import { t as settlesWithin } from "./settle-within-CTpJLoBG.mjs"; import { i as logWarn, t as logDebug } from "./logger-DZaOV_pr.mjs"; import { n as recordAgentCleanupFailure } from "./run-cleanup-timeout-CHdeH1f3.mjs"; import { n as resolveStdioMcpServerLaunchConfig, t as describeStdioMcpServerLaunchConfig } from "./mcp-stdio-D6gC_qOd.mjs"; import { a as resolveWindowsSpawnProgram, r as materializeWindowsSpawnProgram } from "./windows-spawn-Eu3IPAL5.mjs"; import { n as closeOwnedStdioProcess, r as createOwnedStdioProcess, t as OwnedStdioCleanupError } from "./owned-stdio-lSi8DXGi.mjs"; import { a as setPluginToolMeta } from "./tool-metadata-D5DsuLcG.mjs"; import { n as loadEnabledBundleLspConfig } from "./bundle-lsp-BukCELtE.mjs"; import { t as createPendingRequestRegistry } from "./pending-request-registry-6Top3fRe.mjs"; //#region src/agents/agent-bundle-lsp-process.ts /** Spawns bundled LSP server processes with sanitized environment and platform handling. */ async function spawnLspServerProcess(config, options = {}) { const mergedEnv = sanitizeHostExecEnv({ baseEnv: process.env, overrides: config.env ?? null }); const program = resolveWindowsSpawnProgram({ command: config.command, env: mergedEnv, allowShellFallback: true }); const invocation = materializeWindowsSpawnProgram(program, config.args ?? []); return await createOwnedStdioProcess({ argv: [invocation.command, ...invocation.argv], env: mergedEnv, exactEnv: true, cwd: config.cwd, abortSignal: options.abortSignal, ...invocation.shell === true ? { windowsShell: true } : {} }); } //#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 createLspSession(serverName, child) { return { serverName, process: child, requestId: 0, pendingRequests: createPendingRequestRegistry(), input: { buffer: Buffer.alloc(0), length: 0 }, initialized: false, capabilities: {}, disposed: false, forceClose: false }; } function failLspSession(session, error) { session.failure ??= error; session.pendingRequests.rejectAll(session.failure); } function lspProcessExitError(session, code, signal) { return /* @__PURE__ */ new Error(`LSP server "${session.serverName}" exited (${signal ?? code ?? "unknown"})`); } function attachLspProcessHandlers(session) { session.process.onError((error, source) => { if (source === "stderr") logWarn(`bundle-lsp:${session.serverName}: stderr failed: ${String(error)}`); else { session.forceClose = true; failLspSession(session, error); } }); session.process.onExit((code, signal) => { session.failure ??= lspProcessExitError(session, code, signal); }); session.process.wait().then(({ code, signal }) => failLspSession(session, lspProcessExitError(session, code, signal)), (error) => failLspSession(session, toErrorObject(error, "LSP process failed"))); session.process.onStdout(() => {}, (chunk) => handleIncomingData(session, chunk)); session.process.onStderr((chunk) => { for (const line of chunk.split(/\r?\n/).filter(Boolean)) logDebug(`bundle-lsp:${session.serverName}: ${line.trim()}`); }); } 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(input) { const messages = []; let consumed = 0; while (true) { if (!input.body) { const remaining = input.buffer.subarray(consumed, input.length); 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, consumed }; } 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 start = consumed + headerEnd + LSP_HEADER_SEPARATOR.length; input.body = { start, end: start + contentLength }; } if (input.length < input.body.end) return { ok: true, messages, consumed }; let body; try { body = LSP_BODY_DECODER.decode(input.buffer.subarray(input.body.start, input.body.end)); } 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)}`); } consumed = input.body.end; input.body = void 0; } } function lspAbortError(signal) { return signal?.reason instanceof Error ? signal.reason : createAbortError("LSP request aborted", { cause: signal?.reason }); } function throwIfLspAborted(signal) { if (signal?.aborted) throw lspAbortError(signal); } 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 } })); } 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); } catch (error) { pending.reject(error); } return pending.promise; } function handleIncomingData(session, chunk) { const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; const input = session.input; const length = input.length + bytes.length; if (length > input.buffer.length) { const capacity = Math.max(length, Math.min(Math.max(4096, input.buffer.length * 2), MAX_LSP_HEADER_BYTES + LSP_HEADER_SEPARATOR.length + MAX_LSP_BODY_BYTES)); const buffer = Buffer.allocUnsafe(capacity); input.buffer.copy(buffer, 0, 0, input.length); input.buffer = buffer; } bytes.copy(input.buffer, input.length); input.length = length; const parsed = parseLspMessages(input); if (!parsed.ok || parsed.consumed === input.length) session.input = { buffer: Buffer.alloc(0), length: 0 }; else if (parsed.consumed > 0) { input.buffer = Buffer.from(input.buffer.subarray(parsed.consumed, input.length)); input.length -= parsed.consumed; if (input.body) { input.body.start -= parsed.consumed; input.body.end -= parsed.consumed; } } 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) { session.forceClose = true; failLspSession(session, parsed.error); disposeSession(session).catch(() => {}); } } async function initializeSession(session, signal) { const result = await sendRequest(session, "initialize", { processId: process.pid, rootUri: null, capabilities: { textDocument: { hover: { contentFormat: ["plaintext", "markdown"] }, completion: { completionItem: { snippetSupport: false } }, definition: {}, references: {} } } }, signal); throwIfLspAborted(signal); session.process.stdin?.write(encodeLspMessage({ jsonrpc: "2.0", method: "initialized", params: {} })); session.initialized = true; return result?.capabilities ?? {}; } function disposeSession(session) { if (session.disposal) return session.disposal; session.disposed = true; session.pendingRequests.rejectAll(lspSessionDisposedError()); session.disposal = (async () => { if (session.initialized && !session.failure) try { const shutdown = sendRequest(session, "shutdown").catch(() => void 0); await settlesWithin(shutdown, LSP_SHUTDOWN_GRACE_MS); session.process.stdin?.write(encodeLspMessage({ jsonrpc: "2.0", method: "exit", params: null })); } catch {} session.pendingRequests.rejectAll(lspSessionDisposedError()); try { await closeOwnedStdioProcess(session.process, { graceMs: LSP_PROCESS_TREE_KILL_GRACE_MS, force: session.forceClose }); } catch (error) { recordAgentCleanupFailure(); logWarn(`bundle-lsp:${session.serverName}: process cleanup could not confirm descendant shutdown; inspect this server before retrying automatic recovery.`); throw error; } finally { activeBundleLspSessions.delete(session); } })(); return session.disposal; } async function disposeSessions(sessions) { if ((await Promise.allSettled(Array.from(sessions, (session) => disposeSession(session)))).some((result) => result.status === "rejected")) recordAgentCleanupFailure(); } function createLspPositionTool(params) { return { name: `lsp_${params.method}_${params.session.serverName}`, 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" }, ...params.method === "references" ? { includeDeclaration: { type: "boolean", description: "Include the declaration in results" } } : {} }, required: [ "uri", "line", "character" ] }, execute: async (_toolCallId, input, signal) => { const position = input; const result = await sendRequest(params.session, `textDocument/${params.method}`, { textDocument: { uri: position.uri }, position: { line: position.line, character: position.character }, ...params.method === "references" ? { context: { includeDeclaration: position.includeDeclaration ?? true } } : {} }, signal); return { content: [{ type: "text", text: result !== null && result !== void 0 ? JSON.stringify(result, null, 2) : `No ${params.method} result from ${params.session.serverName}` }], details: { lspServer: params.session.serverName, lspMethod: params.method } }; } }; } function buildLspTools(session) { const serverLabel = session.serverName; return [ { method: "hover", label: `LSP Hover (${serverLabel})`, description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.` }, { method: "definition", 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: "references", label: `LSP Find References (${serverLabel})`, description: `Find all references to a symbol at a position in a file via the ${serverLabel} language server.` } ].filter(({ method }) => session.capabilities[`${method}Provider`]).map((definition) => createLspPositionTool({ session, ...definition })); } async function createBundleLspToolRuntime(params) { throwIfLspAborted(params.abortSignal); const loaded = loadEnabledBundleLspConfig({ 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.config.lspServers).length === 0) return { tools: [], sessions: [], dispose: async () => {} }; const reservedNames = normalizeReservedToolNames(params.reservedToolNames); const sessions = []; const tools = []; try { for (const [serverName, rawServer] of Object.entries(loaded.config.lspServers)) { throwIfLspAborted(params.abortSignal); 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, await spawnLspServerProcess(launchConfig, { abortSignal: params.abortSignal })); activeBundleLspSessions.add(session); attachLspProcessHandlers(session); throwIfLspAborted(params.abortSignal); const capabilities = await initializeSession(session, params.abortSignal); throwIfLspAborted(params.abortSignal); 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 disposeSessions([session]); else if (error instanceof OwnedStdioCleanupError) recordAgentCleanupFailure(); if (!params.abortSignal?.aborted || error instanceof OwnedStdioCleanupError) logWarn(`bundle-lsp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`); throwIfLspAborted(params.abortSignal); } } return { tools, sessions: sessions.map((s) => ({ serverName: s.serverName, capabilities: s.capabilities })), dispose: () => disposeSessions(sessions) }; } catch (error) { await disposeSessions(sessions); throw error; } } async function disposeAllBundleLspRuntimes() { await disposeSessions(activeBundleLspSessions); } //#endregion export { disposeAllBundleLspRuntimes as n, createBundleLspToolRuntime as t };