UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

704 lines (703 loc) 24 kB
import { s as normalizeOptionalLowercaseString } from "./string-coerce-mnp54Vah.js"; import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import { t as createLazyImportLoader } from "./lazy-promise-BONnzNfb.js"; import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js"; import { c as isWithinDir } from "./path-BlG8lhgR.js"; import "./fs-safe-aqmM_n6V.js"; import { a as root } from "./secure-temp-dir-XAWcZnE2.js"; import { o as safePathSegmentHashed, r as assertCanonicalPathWithinBase } from "./install-safe-path-C0w7ALW6.js"; import { d as resolveConfigDir, p as resolveUserPath, s as ensureDir } from "./utils-CCC-BEJH.js"; import "./path-safety-CBe_wA_B.js"; import { r as runCommandWithTimeout } from "./exec-DubsSJS2.js"; import { t as isContainerEnvironment } from "./container-environment-CNsJSTpY.js"; import { h as isWindowsDrivePath } from "./archive-Dcpo6Wva.js"; import { r as mutateConfigFileWithRetry } from "./config-C9RxTsn1.js"; import { r as fetchWithSsrFGuard } from "./fetch-guard-BttkNCLm.js"; import "./sandbox-paths-DAj1Euvz.js"; import { n as normalizeSecretInput } from "./normalize-secret-input-OwRUfByL.js"; import { a as resolveSkillsInstallPreferences, u as resolveSkillKey } from "./config-CEJFxFUy.js"; import { t as resolveSkillSource } from "./source-UVo0hlSr.js"; import { s as loadWorkspaceSkillEntries } from "./workspace-_gXfieOh.js"; import { n as hasBinary } from "./config-eval-CAHzuwOy.js"; import "./redact-snapshot-CuoBedjD.js"; import { t as evaluateSkillInstallPolicy } from "./install-security-scan-DbaM_3Fm.js"; import { t as resolveBrewExecutable } from "./brew-BuAbPCrG.js"; import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { randomUUID } from "node:crypto"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; //#region src/skills/config/mutations.ts function patchSkillConfigEntry(cfg, skillKey, patch) { const entries = { ...cfg.skills?.entries }; const current = entries[skillKey] ? { ...entries[skillKey] } : {}; if (typeof patch.enabled === "boolean") current.enabled = patch.enabled; if (typeof patch.apiKey === "string") { const trimmed = normalizeSecretInput(patch.apiKey); if (trimmed === "__OPENCLAW_REDACTED__") {} else if (trimmed) current.apiKey = trimmed; else delete current.apiKey; } if (patch.env && typeof patch.env === "object") { const nextEnv = current.env ? { ...current.env } : {}; for (const [key, value] of Object.entries(patch.env)) { const trimmedKey = key.trim(); if (!trimmedKey) continue; const trimmedVal = value.trim(); if (trimmedVal === "__OPENCLAW_REDACTED__") continue; if (!trimmedVal) delete nextEnv[trimmedKey]; else nextEnv[trimmedKey] = trimmedVal; } current.env = nextEnv; } entries[skillKey] = current; return { ...cfg, skills: { ...cfg.skills, entries } }; } async function updateSkillConfigEntry(params) { return (await mutateConfigFileWithRetry({ afterWrite: { mode: "auto" }, mutate: (draft) => { const next = patchSkillConfigEntry(draft, params.skillKey, params); Object.assign(draft, next); return next.skills?.entries?.[params.skillKey] ?? {}; } })).result ?? {}; } //#endregion //#region src/skills/runtime/tools-dir.ts /** Resolves a skill's tools directory relative to the OpenClaw config dir. */ function resolveSkillToolsRootDir(entry) { const safeKey = safePathSegmentHashed(resolveSkillKey(entry.skill, entry)); return path.join(resolveConfigDir(), "tools", safeKey); } //#endregion //#region src/skills/lifecycle/install-output.ts function summarizeInstallOutput(text) { const raw = text.trim(); if (!raw) return; const lines = normalizeStringEntries(raw.split("\n")); if (lines.length === 0) return; const preferred = lines.find((line) => /^error\b/i.test(line)) ?? lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ?? lines.at(-1); if (!preferred) return; const normalized = preferred.replace(/\s+/g, " ").trim(); const maxLen = 200; return normalized.length > maxLen ? `${normalized.slice(0, maxLen - 1)}…` : normalized; } /** Formats a bounded install failure message from command exit and output. */ function formatInstallFailureMessage(result) { const code = typeof result.code === "number" ? `exit ${result.code}` : "unknown exit"; const summary = summarizeInstallOutput(result.stderr) ?? summarizeInstallOutput(result.stdout); if (!summary) return `Install failed (${code})`; return `Install failed (${code}): ${summary}`; } //#endregion //#region src/skills/lifecycle/install-download.ts const extractModuleLoader = createLazyImportLoader(() => import("./install-extract-CfNaQ1g3.js")); async function loadExtractModule() { return await extractModuleLoader.load(); } function isNodeReadableStream(value) { return Boolean(value && typeof value.pipe === "function"); } async function cancelIgnoredResponseBody(response) { const body = response.body; const cancel = body && typeof body.cancel === "function" ? body.cancel : void 0; if (!cancel) return; await Promise.resolve(cancel.call(body)).catch(() => void 0); } function resolveDownloadTargetDir(entry, spec) { const root = resolveSkillToolsRootDir(entry); const raw = spec.targetDir?.trim(); if (!raw) return root; const resolved = raw.startsWith("~") || path.isAbsolute(raw) || isWindowsDrivePath(raw) ? resolveUserPath(raw) : path.resolve(root, raw); if (!isWithinDir(root, resolved)) throw new Error(`Refusing to install outside the skill tools directory. targetDir="${raw}" resolves to "${resolved}". Allowed root: "${root}".`); return resolved; } function resolveArchiveType(spec, filename) { const explicit = normalizeOptionalLowercaseString(spec.archive); if (explicit) return explicit; const lower = normalizeOptionalLowercaseString(filename); if (!lower) return; if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) return "tar.gz"; if (lower.endsWith(".tar.bz2") || lower.endsWith(".tbz2")) return "tar.bz2"; if (lower.endsWith(".zip")) return "zip"; } async function downloadFile(params) { const destPath = path.resolve(params.rootDir, params.relativePath); const stagingDir = path.join(params.rootDir, ".openclaw-download-staging"); await ensureDir(stagingDir); await assertCanonicalPathWithinBase({ baseDir: params.rootDir, candidatePath: stagingDir, boundaryLabel: "skill tools directory" }); const tempPath = path.join(stagingDir, `${randomUUID()}.tmp`); const { response, release } = await fetchWithSsrFGuard({ url: params.url, timeoutMs: Math.max(1e3, params.timeoutMs) }); try { if (!response.ok || !response.body) { await cancelIgnoredResponseBody(response); throw new Error(`Download failed (${response.status} ${response.statusText})`); } const file = fs.createWriteStream(tempPath); const body = response.body; await pipeline(isNodeReadableStream(body) ? body : Readable.fromWeb(body), file); await (await root(params.rootDir)).copyIn(params.relativePath, tempPath); return { bytes: (await fs.promises.stat(destPath)).size }; } finally { await fs.promises.rm(tempPath, { force: true }).catch(() => void 0); await release(); } } async function installDownloadSpec(params) { const { entry, spec, timeoutMs } = params; const root = resolveSkillToolsRootDir(entry); const url = spec.url?.trim(); if (!url) return { ok: false, message: "missing download url", stdout: "", stderr: "", code: null }; let filename; try { const parsed = new URL(url); filename = path.basename(parsed.pathname); } catch { filename = path.basename(url); } if (!filename) filename = "download"; let canonicalRoot; let targetDir; try { await ensureDir(root); await assertCanonicalPathWithinBase({ baseDir: root, candidatePath: root, boundaryLabel: "skill tools directory" }); canonicalRoot = await fs.promises.realpath(root); const requestedTargetDir = resolveDownloadTargetDir(entry, spec); await ensureDir(requestedTargetDir); await assertCanonicalPathWithinBase({ baseDir: root, candidatePath: requestedTargetDir, boundaryLabel: "skill tools directory" }); const targetRelativePath = path.relative(root, requestedTargetDir); targetDir = path.join(canonicalRoot, targetRelativePath); } catch (err) { const message = formatErrorMessage(err); return { ok: false, message, stdout: "", stderr: message, code: null }; } const archivePath = path.join(targetDir, filename); const archiveRelativePath = path.relative(canonicalRoot, archivePath); if (!archiveRelativePath || archiveRelativePath === ".." || archiveRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(archiveRelativePath)) return { ok: false, message: "invalid download archive path", stdout: "", stderr: "invalid download archive path", code: null }; let downloaded; try { downloaded = (await downloadFile({ url, rootDir: canonicalRoot, relativePath: archiveRelativePath, timeoutMs })).bytes; } catch (err) { const message = formatErrorMessage(err); return { ok: false, message, stdout: "", stderr: message, code: null }; } const archiveType = resolveArchiveType(spec, filename); if (!(spec.extract ?? Boolean(archiveType))) return { ok: true, message: `Downloaded to ${archivePath}`, stdout: `downloaded=${downloaded}`, stderr: "", code: 0 }; if (!archiveType) return { ok: false, message: "extract requested but archive type could not be detected", stdout: "", stderr: "", code: null }; try { await assertCanonicalPathWithinBase({ baseDir: canonicalRoot, candidatePath: targetDir, boundaryLabel: "skill tools directory" }); } catch (err) { const message = formatErrorMessage(err); return { ok: false, message, stdout: "", stderr: message, code: null }; } const { extractArchive } = await loadExtractModule(); const extractResult = await extractArchive({ archivePath, archiveType, targetDir, stripComponents: spec.stripComponents, timeoutMs }); const success = extractResult.code === 0; return { ok: success, message: success ? `Downloaded and extracted to ${targetDir}` : formatInstallFailureMessage(extractResult), stdout: extractResult.stdout.trim(), stderr: extractResult.stderr.trim(), code: extractResult.code }; } let skillsInstallDeps = { hasBinary, loadWorkspaceSkillEntries, resolveNodeInstallStateDir: resolveDefaultNodeInstallStateDir, resolveBrewExecutable, isContainerEnvironment, resolveSkillsInstallPreferences }; function getSkillsInstallDeps() { return skillsInstallDeps; } function withWarnings(result, warnings) { if (warnings.length === 0) return result; return { ...result, warnings: warnings.slice() }; } function resolveInstallId(spec, index) { return (spec.id ?? `${spec.kind}-${index}`).trim(); } function findInstallSpec(entry, installId) { const specs = entry.metadata?.install ?? []; for (const [index, spec] of specs.entries()) if (resolveInstallId(spec, index) === installId) return spec; } function normalizeSkillInstallSpec(spec) { return { ...spec.id ? { id: spec.id } : {}, kind: spec.kind, ...spec.label ? { label: spec.label } : {}, ...spec.bins ? { bins: spec.bins.slice() } : {}, ...spec.os ? { os: spec.os.slice() } : {}, ...spec.formula ? { formula: spec.formula } : {}, ...spec.package ? { package: spec.package } : {}, ...spec.module ? { module: spec.module } : {}, ...spec.url ? { url: spec.url } : {}, ...spec.archive ? { archive: spec.archive } : {}, ...spec.extract !== void 0 ? { extract: spec.extract } : {}, ...spec.stripComponents !== void 0 ? { stripComponents: spec.stripComponents } : {}, ...spec.targetDir ? { targetDir: spec.targetDir } : {} }; } function buildNodeInstallCommand(packageName, prefs) { switch (prefs.nodeManager) { case "pnpm": return [ "pnpm", "add", "-g", "--ignore-scripts", packageName ]; case "yarn": return [ "yarn", "global", "add", "--ignore-scripts", packageName ]; case "bun": return [ "bun", "add", "-g", "--ignore-scripts", packageName ]; default: return [ "npm", "install", "-g", "--ignore-scripts", packageName ]; } } function resolveDefaultNodeInstallStateDir({ cwd = process.cwd(), getuid = process.getuid?.bind(process), homedir = os.homedir, platform = process.platform } = {}) { if (platform !== "win32" && getuid?.() === 0) return path.join(path.parse(cwd).root, "var", "lib", "openclaw"); return path.join(homedir(), ".openclaw"); } async function buildNodeInstallEnv(prefs) { if (prefs.nodeManager !== "npm") return {}; const stateDir = getSkillsInstallDeps().resolveNodeInstallStateDir(); const prefix = path.join(stateDir, "tools", "node", "npm"); await fs.promises.mkdir(prefix, { recursive: true, mode: 448 }); return { NPM_CONFIG_PREFIX: prefix, npm_config_prefix: prefix }; } const SAFE_BREW_FORMULA = /^[a-z0-9][a-z0-9+._@-]*(\/[a-z0-9][a-z0-9+._@-]*){0,2}$/; const SAFE_NODE_PACKAGE = /^(@[a-z0-9._-]+\/)?[a-z0-9._-]+(@[a-z0-9^~>=<.*|-]+)?$/; const SAFE_GO_MODULE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*@[a-z0-9v._-]+$/; const SAFE_UV_PACKAGE = /^[a-z0-9][a-z0-9._-]*(\[[a-z0-9,._-]+\])?(([><=!~]=?|===?)[a-z0-9.*_-]+)?$/i; function assertSafeInstallerValue(value, kind, pattern) { const trimmed = value.trim(); if (!trimmed || trimmed.startsWith("-")) return `${kind} value is empty or starts with a dash`; if (!pattern.test(trimmed)) return `${kind} value contains invalid characters: ${trimmed}`; return null; } function buildInstallCommand(spec, prefs) { switch (spec.kind) { case "brew": { if (!spec.formula) return { argv: null, error: "missing brew formula" }; const err = assertSafeInstallerValue(spec.formula, "brew formula", SAFE_BREW_FORMULA); if (err) return { argv: null, error: err }; return { argv: [ "brew", "install", spec.formula.trim() ] }; } case "node": { if (!spec.package) return { argv: null, error: "missing node package" }; const err = assertSafeInstallerValue(spec.package, "node package", SAFE_NODE_PACKAGE); if (err) return { argv: null, error: err }; return { argv: buildNodeInstallCommand(spec.package.trim(), prefs) }; } case "go": { if (!spec.module) return { argv: null, error: "missing go module" }; const err = assertSafeInstallerValue(spec.module, "go module", SAFE_GO_MODULE); if (err) return { argv: null, error: err }; return { argv: [ "go", "install", spec.module.trim() ] }; } case "uv": { if (!spec.package) return { argv: null, error: "missing uv package" }; const err = assertSafeInstallerValue(spec.package, "uv package", SAFE_UV_PACKAGE); if (err) return { argv: null, error: err }; return { argv: [ "uv", "tool", "install", spec.package.trim() ] }; } case "download": return { argv: null, error: "download install handled separately" }; default: return { argv: null, error: "unsupported installer" }; } } async function resolveBrewBinDir(timeoutMs, brewExe) { const deps = getSkillsInstallDeps(); const exe = brewExe ?? (deps.hasBinary("brew") ? "brew" : deps.resolveBrewExecutable()); if (!exe) return; const prefixResult = await runCommandWithTimeout([exe, "--prefix"], { timeoutMs: Math.min(timeoutMs, 3e4) }); if (prefixResult.code === 0) { const prefix = prefixResult.stdout.trim(); if (prefix) return path.join(prefix, "bin"); } for (const candidate of ["/opt/homebrew/bin", "/usr/local/bin"]) try { if (fs.existsSync(candidate)) return candidate; } catch {} } function createInstallFailure(params) { return { ok: false, message: params.message, stdout: params.stdout?.trim() ?? "", stderr: params.stderr?.trim() ?? "", code: params.code ?? null }; } function createInstallSuccess(result) { return { ok: true, message: "Installed", stdout: result.stdout.trim(), stderr: result.stderr.trim(), code: result.code }; } async function runCommandSafely(argv, optionsOrTimeout) { try { const result = await runCommandWithTimeout(argv, optionsOrTimeout); return { code: result.code, stdout: result.stdout, stderr: result.stderr }; } catch (err) { return { code: null, stdout: "", stderr: formatErrorMessage(err) }; } } async function runBestEffortCommand(argv, optionsOrTimeout) { await runCommandSafely(argv, optionsOrTimeout); } function resolveBrewMissingFailure(spec) { const formula = spec.formula ?? "this package"; if (process.platform === "linux" && getSkillsInstallDeps().isContainerEnvironment()) return createInstallFailure({ message: `brew not installed — Homebrew is not installed in this Linux container. Build a custom image with Homebrew or install "${formula}" manually using a supported system package before enabling this skill.` }); return createInstallFailure({ message: `brew not installed — ${process.platform === "linux" ? `Homebrew is not installed. Install it from https://brew.sh or install "${formula}" manually using your system package manager (e.g. apt, dnf, pacman).` : "Homebrew is not installed. Install it from https://brew.sh"}` }); } async function ensureUvInstalled(params) { if (params.spec.kind !== "uv" || getSkillsInstallDeps().hasBinary("uv")) return; if (!params.brewExe) return createInstallFailure({ message: "uv not installed — install manually: https://docs.astral.sh/uv/getting-started/installation/" }); const brewResult = await runCommandSafely([ params.brewExe, "install", "uv" ], { timeoutMs: params.timeoutMs }); if (brewResult.code === 0) return; return createInstallFailure({ message: "Failed to install uv (brew)", ...brewResult }); } async function installGoViaApt(timeoutMs) { const aptInstallArgv = [ "apt-get", "install", "-y", "golang-go" ]; const aptUpdateArgv = [ "apt-get", "update", "-qq" ]; const aptFailureMessage = "go not installed — automatic install via apt failed. Install manually: https://go.dev/doc/install"; if (typeof process.getuid === "function" && process.getuid() === 0) { await runBestEffortCommand(aptUpdateArgv, { timeoutMs }); const aptResult = await runCommandSafely(aptInstallArgv, { timeoutMs }); if (aptResult.code === 0) return; return createInstallFailure({ message: aptFailureMessage, ...aptResult }); } if (!getSkillsInstallDeps().hasBinary("sudo")) return createInstallFailure({ message: "go not installed — apt-get is available but sudo is not installed. Install manually: https://go.dev/doc/install" }); const sudoCheck = await runCommandSafely([ "sudo", "-n", "true" ], { timeoutMs: 5e3 }); if (sudoCheck.code !== 0) return createInstallFailure({ message: "go not installed — apt-get is available but sudo is not usable (missing or requires a password). Install manually: https://go.dev/doc/install", ...sudoCheck }); await runBestEffortCommand(["sudo", ...aptUpdateArgv], { timeoutMs }); const aptResult = await runCommandSafely(["sudo", ...aptInstallArgv], { timeoutMs }); if (aptResult.code === 0) return; return createInstallFailure({ message: aptFailureMessage, ...aptResult }); } async function ensureGoInstalled(params) { if (params.spec.kind !== "go" || getSkillsInstallDeps().hasBinary("go")) return; if (params.brewExe) { const brewResult = await runCommandSafely([ params.brewExe, "install", "go" ], { timeoutMs: params.timeoutMs }); if (brewResult.code === 0) return; return createInstallFailure({ message: "Failed to install go (brew)", ...brewResult }); } if (getSkillsInstallDeps().hasBinary("apt-get")) return installGoViaApt(params.timeoutMs); return createInstallFailure({ message: "go not installed — install manually: https://go.dev/doc/install" }); } async function executeInstallCommand(params) { if (!params.argv || params.argv.length === 0) return createInstallFailure({ message: "invalid install command" }); const result = await runCommandSafely(params.argv, { timeoutMs: params.timeoutMs, env: params.env }); if (result.code === 0) return createInstallSuccess(result); return createInstallFailure({ message: formatInstallFailureMessage(result), ...result }); } async function installSkill(params) { const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 3e5, 1e3), 9e5); const workspaceDir = resolveUserPath(params.workspaceDir); const deps = getSkillsInstallDeps(); const entry = deps.loadWorkspaceSkillEntries(workspaceDir).find((item) => item.skill.name === params.skillName); if (!entry) return { ok: false, message: `Skill not found: ${params.skillName}`, stdout: "", stderr: "", code: null }; const spec = findInstallSpec(entry, params.installId); const warnings = []; const skillSource = resolveSkillSource(entry.skill); const normalizedSpec = spec ? normalizeSkillInstallSpec(spec) : void 0; const scanResult = await evaluateSkillInstallPolicy({ config: params.config, installId: params.installId, ...normalizedSpec ? { installSpec: normalizedSpec } : {}, logger: { warn: (message) => warnings.push(message) }, origin: { type: skillSource, skillName: params.skillName, installId: params.installId }, source: skillSource === "openclaw-bundled" ? { kind: "bundled", authority: "openclaw", mutable: false, network: false } : skillSource === "openclaw-managed" || skillSource === "openclaw-extra" ? { kind: "managed", authority: "openclaw", mutable: false, network: false } : { kind: "workspace", authority: "user", mutable: true, network: false }, requestedSpecifier: `${params.skillName}:${params.installId}`, skillName: params.skillName, sourceDir: path.resolve(entry.skill.baseDir) }); if (scanResult?.blocked) return withWarnings({ ok: false, message: scanResult.blocked.reason, stdout: "", stderr: "", code: null }, warnings); if (!new Set([ "openclaw-bundled", "openclaw-managed", "openclaw-extra" ]).has(skillSource)) warnings.push(`WARNING: Skill "${params.skillName}" install triggered from non-bundled source "${skillSource}". Verify the install recipe is trusted.`); if (!spec) return withWarnings({ ok: false, message: `Installer not found: ${params.installId}`, stdout: "", stderr: "", code: null }, warnings); if (spec.kind === "download") return withWarnings(await installDownloadSpec({ entry, spec, timeoutMs }), warnings); const prefs = deps.resolveSkillsInstallPreferences(params.config); const command = buildInstallCommand(spec, prefs); if (command.error) return withWarnings({ ok: false, message: command.error, stdout: "", stderr: "", code: null }, warnings); const brewExe = deps.hasBinary("brew") ? "brew" : deps.resolveBrewExecutable(); if (spec.kind === "brew" && !brewExe) return withWarnings(resolveBrewMissingFailure(spec), warnings); const uvInstallFailure = await ensureUvInstalled({ spec, brewExe, timeoutMs }); if (uvInstallFailure) return withWarnings(uvInstallFailure, warnings); const goInstallFailure = await ensureGoInstalled({ spec, brewExe, timeoutMs }); if (goInstallFailure) return withWarnings(goInstallFailure, warnings); const argv = command.argv ? [...command.argv] : null; if (spec.kind === "brew" && brewExe && argv?.[0] === "brew") argv[0] = brewExe; const envOverrides = {}; if (spec.kind === "node") Object.assign(envOverrides, await buildNodeInstallEnv(prefs)); if (spec.kind === "go" && brewExe) { const brewBin = await resolveBrewBinDir(timeoutMs, brewExe); if (brewBin) envOverrides.GOBIN = brewBin; } return withWarnings(await executeInstallCommand({ argv, timeoutMs, env: Object.keys(envOverrides).length > 0 ? envOverrides : void 0 }), warnings); } //#endregion export { patchSkillConfigEntry as n, updateSkillConfigEntry as r, installSkill as t };