// Probe script for bundled plugin install/uninstall E2E scenarios. import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { readPluginInstallRecords } from "../plugin-index-sqlite.mjs"; const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8")); const normalizePathForProbe = (value) => String(value ?? "").replace(/\\/g, "/"); const bundledRuntimeFragments = (pluginDir) => [ `/dist/extensions/${pluginDir}`, `/dist-runtime/extensions/${pluginDir}`, ]; const bundledRuntimeRootFragments = ["/dist/extensions/", "/dist-runtime/extensions/"]; const DEFAULT_PLUGIN_LIST_TIMEOUT_MS = 30_000; const DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES = 4 * 1024 * 1024; function readIntegerEnv(name, fallback, minimum) { const raw = process.env[name]; if (raw == null || raw === "") { return fallback; } const text = raw.trim(); if (!/^\d+$/u.test(text)) { throw new Error(`invalid ${name}: ${text}`); } const value = Number(text); if (!Number.isSafeInteger(value) || value < minimum) { throw new Error(`invalid ${name}: ${text}`); } return value; } function readPositiveIntEnv(name, fallback) { return readIntegerEnv(name, fallback, 1); } function readNonNegativeIntEnv(name, fallback) { return readIntegerEnv(name, fallback, 0); } function resolveStateDir() { if (process.env.OPENCLAW_STATE_DIR) { return process.env.OPENCLAW_STATE_DIR; } return path.join(process.env.HOME || os.homedir(), ".openclaw"); } function pathReferencesBundledRuntime(value, pluginDir) { const normalized = normalizePathForProbe(value); return bundledRuntimeFragments(pluginDir).some((fragment) => normalized.includes(fragment)); } function pathReferencesPackagedBundledRoot(value) { const normalized = normalizePathForProbe(value); return bundledRuntimeRootFragments.some((fragment) => normalized.includes(fragment)); } function pathsEqualForProbe(actual, expected) { return normalizePathForProbe(actual) === normalizePathForProbe(expected); } function resolveOpenClawEntry() { if (process.env.OPENCLAW_ENTRY) { return process.env.OPENCLAW_ENTRY; } for (const entry of ["dist/index.mjs", "dist/index.js"]) { if (fs.existsSync(entry)) { return entry; } } throw new Error("Missing OPENCLAW_ENTRY and dist/index.(m)js"); } function readPluginsList() { const entry = resolveOpenClawEntry(); const timeoutMs = readPositiveIntEnv( "OPENCLAW_BUNDLED_PLUGIN_LIST_TIMEOUT_MS", DEFAULT_PLUGIN_LIST_TIMEOUT_MS, ); const result = spawnSync(process.execPath, [entry, "plugins", "list", "--json"], { cwd: process.cwd(), encoding: "utf8", env: process.env, maxBuffer: readPositiveIntEnv( "OPENCLAW_BUNDLED_PLUGIN_LIST_MAX_BUFFER_BYTES", DEFAULT_PLUGIN_LIST_MAX_BUFFER_BYTES, ), killSignal: "SIGKILL", timeout: timeoutMs, }); if (result.error) { const timedOut = result.error.code === "ETIMEDOUT"; throw new Error( timedOut ? `Timed out listing packaged bundled plugins after ${timeoutMs}ms` : `Unable to list packaged bundled plugins: ${result.error.message}`, ); } if (result.status !== 0) { throw new Error( `Unable to list packaged bundled plugins: ${result.stderr || result.stdout || `exit ${result.status}`}`, ); } const payload = parsePluginListOutput(result.stdout); return Array.isArray(payload.plugins) ? payload.plugins : []; } function parsePluginListOutput(stdout) { const trimmed = stdout.trim(); const parsed = parseJsonValue(trimmed); if (parsed.ok) { return parsed.value; } let lastParsed; for (const line of trimmed.split(/\r?\n/u).toReversed()) { if (!line.trimStart().startsWith("{")) { continue; } const candidate = parseJsonValue(line); if (!candidate.ok) { continue; } lastParsed ??= candidate.value; if (Array.isArray(candidate.value?.plugins)) { return candidate.value; } } if (lastParsed !== undefined) { return lastParsed; } throw new Error(`Unable to parse packaged bundled plugin list JSON: ${trimmed}`); } function parseJsonValue(text) { try { return { ok: true, value: JSON.parse(text) }; } catch { return { ok: false }; } } function pluginRequiresConfig(pluginDir) { const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); if (!fs.existsSync(manifestPath)) { throw new Error(`missing bundled plugin manifest: ${manifestPath}`); } const manifest = readJson(manifestPath); const required = manifest.configSchema?.required; return Array.isArray(required) && required.some((value) => typeof value === "string"); } async function loadPackagedBundledEntries() { return readPluginsList() .filter((plugin) => plugin?.origin === "bundled") .map((plugin) => { const id = typeof plugin.id === "string" ? plugin.id.trim() : ""; const rootDir = typeof plugin.rootDir === "string" ? plugin.rootDir.trim() : ""; const source = typeof plugin.source === "string" ? plugin.source.trim() : ""; const pluginDir = rootDir || (source ? path.dirname(source) : ""); if (!id || !pluginDir || !pathReferencesPackagedBundledRoot(pluginDir)) { return null; } return { id, dir: path.basename(pluginDir), rootDir: pluginDir, requiresConfig: pluginRequiresConfig(pluginDir), }; }) .filter(Boolean) .toSorted((a, b) => a.id.localeCompare(b.id)); } async function loadManifestEntries() { const explicit = (process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS || "") .split(/[,\s]+/u) .map((entry) => entry.trim()) .filter(Boolean); const manifestEntries = await loadPackagedBundledEntries(); if (explicit.length === 0) { return manifestEntries; } const available = manifestEntries.map((entry) => entry.id).join(", "); return explicit.map((lookup) => { const found = manifestEntries.find((entry) => entry.id === lookup || entry.dir === lookup); if (!found) { throw new Error( `OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS entry is not an installable bundled plugin in this package: ${lookup}. Available: ${available}`, ); } return found; }); } async function selectedManifestEntries() { const allEntries = await loadManifestEntries(); const total = readPositiveIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL", 1); const index = readNonNegativeIntEnv("OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX", 0); if (index >= total) { throw new Error( `OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX must be in [0, ${total - 1}], got ${process.env.OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX}`, ); } const selected = allEntries.filter((_, candidateIndex) => candidateIndex % total === index); if (selected.length === 0) { throw new Error(`No bundled plugin ids selected for shard ${index}/${total}`); } return selected; } function assertInstalled(pluginId, pluginDir, requiresConfig, selectedPluginRoot = "") { const stateDir = resolveStateDir(); const configPath = path.join(stateDir, "openclaw.json"); const config = readJson(configPath); const records = readPluginInstallRecords({ stateDir, configPath }); const record = records[pluginId]; if (!record) { throw new Error(`missing install record for ${pluginId}`); } if (record.source !== "path") { throw new Error( `expected bundled install record source=path for ${pluginId}, got ${record.source}`, ); } const sourcePath = typeof record.sourcePath === "string" ? record.sourcePath : ""; if (!sourcePath) { throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`); } if (selectedPluginRoot && !pathsEqualForProbe(sourcePath, selectedPluginRoot)) { throw new Error( `bundled source path for ${pluginId} did not match selected root: expected ${selectedPluginRoot}, got ${record.sourcePath}`, ); } if (!selectedPluginRoot && !pathReferencesBundledRuntime(sourcePath, pluginDir)) { throw new Error(`unexpected bundled source path for ${pluginId}: ${record.sourcePath}`); } if (selectedPluginRoot && !fs.existsSync(sourcePath)) { throw new Error(`bundled source path for ${pluginId} does not exist: ${record.sourcePath}`); } if (!pathsEqualForProbe(record.installPath, record.sourcePath)) { throw new Error(`bundled install path should equal source path for ${pluginId}`); } const paths = config.plugins?.load?.paths || []; if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) { throw new Error(`config load paths should not include bundled install path for ${pluginId}`); } if (requiresConfig && config.plugins?.entries?.[pluginId]?.enabled === true) { throw new Error( `plugin requiring config should not be enabled immediately after install for ${pluginId}`, ); } if (!requiresConfig && config.plugins?.entries?.[pluginId]?.enabled !== true) { throw new Error(`config entry is not enabled after install for ${pluginId}`); } const allow = config.plugins?.allow || []; if (Array.isArray(allow) && allow.length > 0 && !allow.includes(pluginId)) { throw new Error(`existing allowlist does not include ${pluginId} after install`); } if ((config.plugins?.deny || []).includes(pluginId)) { throw new Error(`denylist contains ${pluginId} after install`); } } function assertUninstalled(pluginId, pluginDir) { const stateDir = resolveStateDir(); const configPath = path.join(stateDir, "openclaw.json"); const config = fs.existsSync(configPath) ? readJson(configPath) : {}; const records = readPluginInstallRecords({ stateDir, configPath }); if (records[pluginId]) { throw new Error(`install record still present after uninstall for ${pluginId}`); } const paths = config.plugins?.load?.paths || []; if (paths.some((entry) => pathReferencesBundledRuntime(entry, pluginDir))) { throw new Error(`load path still present after uninstall for ${pluginId}`); } if (config.plugins?.entries?.[pluginId]) { throw new Error(`config entry still present after uninstall for ${pluginId}`); } if ((config.plugins?.allow || []).includes(pluginId)) { throw new Error(`allowlist still contains ${pluginId} after uninstall`); } if ((config.plugins?.deny || []).includes(pluginId)) { throw new Error(`denylist still contains ${pluginId} after uninstall`); } const managedPath = path.join(stateDir, "extensions", pluginId); if (fs.existsSync(managedPath)) { throw new Error( `managed install directory unexpectedly exists for bundled plugin ${pluginId}: ${managedPath}`, ); } } const [command, pluginId, pluginDir, requiresConfig, selectedPluginRoot] = process.argv.slice(2); if (command === "select") { for (const entry of await selectedManifestEntries()) { console.log(`${entry.id}\t${entry.dir}\t${entry.requiresConfig ? "1" : "0"}\t${entry.rootDir}`); } } else if (command === "assert-installed") { assertInstalled(pluginId, pluginDir, requiresConfig === "1", selectedPluginRoot); } else if (command === "assert-uninstalled") { assertUninstalled(pluginId, pluginDir); } else { throw new Error(`Unknown bundled plugin probe command: ${command || "(missing)"}`); }