openclaw/scripts/test-built-plugin-singleton.mts
Peter Steinberger 7caee97bb4
fix(test): isolate built smoke runtime state
Set temporary home, OpenClaw state/config, and XDG paths before built runtime imports. Reuse the isolated environment for every singleton loader and the bundled-channel subprocess. Cover inherited-path leakage through a synthetic built status runtime.
2026-10-11 09:40:44 -07:00

492 lines
18 KiB
TypeScript

// Smoke-tests the built plugin loader singleton and bundled plugin runtime overlay.
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { createBuildSmokeEnv } from "./lib/build-smoke-env.mts";
import { withDistArtifactOwnership } from "./lib/dist-artifact-ownership.mts";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { installProcessWarningFilter } from "./process-warning-filter.mts";
import { prepareBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mts";
installProcessWarningFilter();
const repoRoot = resolveRepoRoot(import.meta.url);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-build-smoke-"));
const smokeEnv = createBuildSmokeEnv(tempRoot);
// Built modules can resolve state during import, before loader options are passed.
Object.assign(process.env, smokeEnv);
async function runBuiltPluginSingletonSmoke(signal: AbortSignal) {
signal.throwIfAborted();
const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js");
assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`);
const {
buildPluginRuntimeLoadOptions,
clearPluginCommands,
getPluginCommandSpecs,
getPluginModuleLoaderStats,
loadOpenClawPlugins,
matchPluginCommand,
resolvePluginRuntimeLoadContext,
setPluginRuntimeLoadContext,
withPluginRuntimeGenerationScope,
} = await import(pathToFileURL(smokeEntryPath).href);
signal.throwIfAborted();
assert.equal(typeof loadOpenClawPlugins, "function", "built loader export missing");
assert.equal(typeof clearPluginCommands, "function", "clearPluginCommands missing");
assert.equal(typeof getPluginCommandSpecs, "function", "getPluginCommandSpecs missing");
assert.equal(typeof getPluginModuleLoaderStats, "function", "plugin loader stats missing");
assert.equal(typeof matchPluginCommand, "function", "matchPluginCommand missing");
const pluginId = "build-smoke-plugin";
const distPluginDir = path.join(repoRoot, "dist", "extensions", pluginId);
const runtimePluginDir = path.join(repoRoot, "dist-runtime", "extensions", pluginId);
function cleanup() {
clearPluginCommands();
fs.rmSync(distPluginDir, { recursive: true, force: true });
fs.rmSync(runtimePluginDir, { recursive: true, force: true });
}
process.on("exit", cleanup);
try {
fs.mkdirSync(distPluginDir, { recursive: true });
fs.writeFileSync(
path.join(distPluginDir, "package.json"),
JSON.stringify(
{
name: "@openclaw/build-smoke-plugin",
type: "module",
openclaw: {
extensions: ["./index.js"],
},
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
),
"utf8",
);
fs.writeFileSync(
path.join(distPluginDir, "index.js"),
[
"import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk/plugin-entry';",
"",
"export default {",
` id: ${JSON.stringify(pluginId)},`,
" configSchema: emptyPluginConfigSchema(),",
" register(api) {",
" api.registerCommand({",
" name: 'pair',",
" description: 'Pair a device',",
" acceptsArgs: true,",
" nativeNames: { telegram: 'pair', discord: 'pair' },",
" async handler({ args }) {",
" return { text: `paired:${args ?? ''}` };",
" },",
" });",
" },",
"};",
"",
].join("\n"),
"utf8",
);
const runtime = prepareBundledPluginRuntime({ repoRoot });
try {
await runtime.publish(() => signal.throwIfAborted());
} catch (error) {
try {
await runtime.cleanup();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], "Smoke runtime setup and cleanup failed.", {
cause: cleanupError,
});
}
throw error;
}
await runtime.cleanup();
signal.throwIfAborted();
const runtimeEntryPath = path.join(runtimePluginDir, "index.js");
assert.ok(fs.existsSync(runtimeEntryPath), "runtime overlay entry missing");
const smsRuntimeEntryPath = path.join(
repoRoot,
"dist-runtime",
"extensions",
"sms",
"index.js",
);
assert.ok(fs.existsSync(smsRuntimeEntryPath), "compiled SMS runtime entry missing");
assert.ok(
fs.existsSync(
path.join(repoRoot, "dist-runtime", "extensions", "mxc", "mxc-spawn-launcher.mjs"),
),
"compiled MXC runtime asset missing",
);
assert.equal(
fs.existsSync(path.join(repoRoot, "dist-runtime", "plugins", "commands.js")),
false,
"dist-runtime must not stage a duplicate commands module",
);
clearPluginCommands();
const smsStatsBefore = getPluginModuleLoaderStats();
// Prepared runtimes carry this context into late, plugin-scoped loads. Prove that the load-options
// projection retains the built-artifact choice instead of reopening source transformation.
const smsRegistry = loadOpenClawPlugins(
buildPluginRuntimeLoadOptions(
resolvePluginRuntimeLoadContext({
config: {
plugins: {
enabled: true,
allow: ["sms"],
entries: { sms: { enabled: true } },
},
},
env: {
...smokeEnv,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"),
},
workspaceDir: tempRoot,
}),
{ cache: false, onlyPluginIds: ["sms"] },
),
);
const smsRecord = smsRegistry.plugins.find((entry: { id: string }) => entry.id === "sms");
assert.ok(smsRecord, "SMS plugin missing from registry");
assert.equal(smsRecord.status, "loaded", smsRecord.error ?? "SMS plugin failed to load");
const smsStatsAfter = getPluginModuleLoaderStats();
assert.ok(
smsStatsAfter.nativeHits > smsStatsBefore.nativeHits,
"compiled SMS runtime did not use native loading",
);
for (const counter of [
"nativeMisses",
"sourceTransformForced",
"sourceTransformFallbacks",
] as const) {
assert.equal(
smsStatsAfter[counter],
smsStatsBefore[counter],
`compiled SMS runtime changed ${counter}`,
);
}
assert.equal(
smsStatsAfter.topSourceTransformTargets.some(({ target }: { target: string }) =>
target.replaceAll("\\", "/").includes("/extensions/sms/"),
),
false,
"compiled SMS runtime reached the source transformer",
);
// Exercise the real built load owner with omitted preferences, as provider and
// tool callers do. Source-owned metadata must not force source execution.
const { resolvePluginDiscoveryProvidersRuntime } = await import(
pathToFileURL(path.join(repoRoot, "dist", "plugins", "provider-discovery.runtime.js")).href
);
signal.throwIfAborted();
const { createPluginMetadataSnapshotFixture } =
await import("../src/plugins/plugin-metadata.test-support.js");
signal.throwIfAborted();
const artifactPluginId = "build-artifact-selection";
const artifactSourceRoot = path.join(tempRoot, "extensions", artifactPluginId);
const artifactBuiltRoot = path.join(tempRoot, "dist", "extensions", artifactPluginId);
fs.mkdirSync(artifactSourceRoot, { recursive: true });
fs.mkdirSync(artifactBuiltRoot, { recursive: true });
fs.mkdirSync(path.join(artifactSourceRoot, "dist"));
const artifactEntry = (label: string) => `export default {
id: ${JSON.stringify(artifactPluginId)},
register(api) {
api.registerProvider({ id: ${JSON.stringify(artifactPluginId)}, label: ${JSON.stringify(label)}, auth: [] });
api.registerTool({ name: "artifact_probe", label: "Artifact", description: ${JSON.stringify(label)},
parameters: { type: "object", properties: {} },
async execute() { return { content: [{ type: "text", text: ${JSON.stringify(label)} }] }; }
});
}
};\n`;
const artifactSource = path.join(artifactSourceRoot, "index.ts");
fs.writeFileSync(artifactSource, artifactEntry("source"));
fs.writeFileSync(path.join(artifactBuiltRoot, "index.js"), artifactEntry("compiled"));
fs.writeFileSync(
path.join(artifactSourceRoot, "dist", "index.js"),
artifactEntry("package-local"),
);
fs.writeFileSync(
path.join(artifactSourceRoot, "package.json"),
JSON.stringify({ type: "module" }),
);
for (const [rootDir, extension, label] of [
[artifactSourceRoot, ".ts", "source"],
[artifactBuiltRoot, ".js", "compiled"],
] as const) {
fs.writeFileSync(
path.join(rootDir, `provider-discovery${extension}`),
`export default {
id: ${JSON.stringify(artifactPluginId)}, label: ${JSON.stringify(label)}, auth: [],
catalog: { run: async () => ({ providers: {} }) }
};\n`,
);
}
fs.writeFileSync(
path.join(artifactBuiltRoot, "package.json"),
JSON.stringify({
type: "module",
openclaw: { extensions: ["./index.js"] },
}),
);
const artifactConfig = {
plugins: { allow: [artifactPluginId], entries: { [artifactPluginId]: { enabled: true } } },
};
const artifactManifest = {
id: artifactPluginId,
rootDir: artifactSourceRoot,
source: artifactSource,
origin: "bundled" as const,
channels: [],
providers: [artifactPluginId],
cliBackends: [],
skills: [],
hooks: [],
contracts: { tools: ["artifact_probe"] },
manifestPath: path.join(artifactSourceRoot, "openclaw.plugin.json"),
providerDiscoverySource: path.join(artifactSourceRoot, "provider-discovery.ts"),
configSchema: { type: "object", properties: {}, additionalProperties: false },
packageManifest: { extensions: ["./index.ts"], build: { bundledDist: false } },
};
const artifactManifestRegistry = {
plugins: [artifactManifest],
diagnostics: [],
};
const artifactMetadataSnapshot = createPluginMetadataSnapshotFixture(artifactManifestRegistry);
for (const toolDiscovery of [false, true]) {
for (const preferBuiltPluginArtifacts of [undefined, false]) {
const values = {
config: artifactConfig,
env: { ...smokeEnv, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") },
manifestRegistry: artifactManifestRegistry,
installRecords: {},
preferBuiltPluginArtifacts,
workspaceDir: tempRoot,
};
const options = toolDiscovery
? buildPluginRuntimeLoadOptions(resolvePluginRuntimeLoadContext(values))
: values;
const selected = loadOpenClawPlugins({
...options,
cache: false,
activate: false,
toolDiscovery,
onlyPluginIds: [artifactPluginId],
});
const label = preferBuiltPluginArtifacts === false ? "source" : "compiled";
assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error);
assert.equal(selected.providers[0]?.provider.label, label);
const tool = selected.tools[0]?.factory({ config: artifactConfig });
assert.equal(tool?.description, label);
if (!toolDiscovery) {
setPluginRuntimeLoadContext(selected, resolvePluginRuntimeLoadContext(values));
const providers = withPluginRuntimeGenerationScope(
{ metadataSnapshot: artifactMetadataSnapshot, pluginRegistry: selected },
() =>
resolvePluginDiscoveryProvidersRuntime({
config: artifactConfig,
pluginMetadataSnapshot: artifactMetadataSnapshot,
onlyPluginIds: [artifactPluginId],
discoveryEntriesOnly: true,
}),
);
assert.equal(
providers[0]?.label,
label,
"provider discovery chose a different artifact policy",
);
}
}
}
// Implicit built-host policy leaves installed source alone; explicit true can
// use its package-local output. Neither cache call order may contaminate the other.
for (const [orderIndex, preferences] of [
[undefined, true],
[true, undefined],
].entries()) {
const options = {
config: artifactConfig,
env: { ...smokeEnv, OPENCLAW_STATE_DIR: path.join(tempRoot, "artifact-state") },
workspaceDir: path.join(tempRoot, `cache-order-${orderIndex}`),
manifestRegistry: {
...artifactManifestRegistry,
plugins: [{ ...artifactManifest, origin: "global" }],
},
installRecords: {},
onlyPluginIds: [artifactPluginId],
cache: true,
activate: false,
};
const registries = preferences.map((preferBuiltPluginArtifacts) => {
const selected = loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts });
const label = preferBuiltPluginArtifacts ? "package-local" : "source";
assert.equal(selected.plugins[0]?.status, "loaded", selected.plugins[0]?.error);
assert.equal(
selected.providers[0]?.provider.label,
label,
`cache call order ${orderIndex}`,
);
assert.equal(selected.tools[0]?.factory({ config: artifactConfig })?.description, label);
return selected;
});
for (const [index, preferBuiltPluginArtifacts] of preferences.entries()) {
assert.equal(
loadOpenClawPlugins({ ...options, preferBuiltPluginArtifacts }),
registries[index],
"repeated selection did not reuse its own cached registry",
);
}
}
clearPluginCommands();
const registry = loadOpenClawPlugins({
cache: false,
workspaceDir: tempRoot,
env: {
...smokeEnv,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "dist-runtime", "extensions"),
},
config: {
plugins: {
enabled: true,
allow: [pluginId],
entries: {
[pluginId]: { enabled: true },
},
},
},
});
const record = registry.plugins.find((entry: { id: string }) => entry.id === pluginId);
assert.ok(record, "smoke plugin missing from registry");
assert.equal(record.status, "loaded", record.error ?? "smoke plugin failed to load");
assert.deepEqual(
getPluginCommandSpecs().filter((command: { name: string }) => command.name === "pair"),
[{ name: "pair", description: "Pair a device", acceptsArgs: true }],
);
const match = matchPluginCommand("/pair now");
assert.ok(match, "canonical built command registry did not receive the command");
assert.equal(match.args, "now");
const result = await match.command.handler({ args: match.args });
signal.throwIfAborted();
assert.deepEqual(result, { text: "paired:now" });
// Keep these imports after the cold native checks so they cannot prewarm the loader.
const { buildBundleMcpToolsFromCatalog } = await import(
pathToFileURL(path.join(repoRoot, "dist", "agents", "agent-bundle-mcp-materialize.js")).href
);
signal.throwIfAborted();
const { getPluginToolMeta } = await import(
pathToFileURL(path.join(repoRoot, "dist", "plugins", "tool-metadata.js")).href
);
signal.throwIfAborted();
const { getPluginToolMeta: getSdkPluginToolMeta } = await import(
pathToFileURL(path.join(repoRoot, "dist", "plugin-sdk", "agent-harness-runtime.js")).href
);
signal.throwIfAborted();
const [mcpTool] = buildBundleMcpToolsFromCatalog({
catalog: {
version: 1,
generatedAt: 0,
servers: {
"build-smoke-mcp": {
serverName: "build-smoke-mcp",
safeServerName: "build-smoke-mcp",
launchSummary: "build smoke inventory fixture",
toolCount: 1,
},
},
tools: [
{
serverName: "build-smoke-mcp",
safeServerName: "build-smoke-mcp",
toolName: "lookup",
inputSchema: { type: "object", properties: {} },
fallbackDescription: "Look up a build smoke inventory item",
},
],
},
});
assert.ok(mcpTool, "compiled MCP materializer did not produce a tool");
const mcpMetadata = getPluginToolMeta(mcpTool);
assert.ok(mcpMetadata, "canonical built metadata owner did not receive MCP tool metadata");
assert.equal(mcpMetadata.pluginId, "bundle-mcp");
assert.equal(mcpMetadata.mcp?.serverName, "build-smoke-mcp");
assert.equal(mcpMetadata.mcp?.safeServerName, "build-smoke-mcp");
assert.equal(mcpMetadata.mcp?.toolName, "lookup");
assert.equal(mcpMetadata.mcp?.operation, "tool");
assert.strictEqual(
getSdkPluginToolMeta(mcpTool),
mcpMetadata,
"public agent-harness-runtime SDK did not read the canonical MCP metadata record",
);
} finally {
process.off("exit", cleanup);
cleanup();
}
signal.throwIfAborted();
const remaining = prepareBundledPluginRuntime({ repoRoot });
const changed = remaining.changed;
await remaining.cleanup();
assert.equal(changed, false, "singleton smoke left noncanonical runtime artifacts");
process.stdout.write("[build-smoke] built plugin singleton smoke passed\n");
}
const controller = new AbortController();
let exitCode = 0;
const cancel = (code: number) => {
if (!controller.signal.aborted) {
exitCode = code;
controller.abort(new Error("Built plugin singleton smoke canceled."));
}
};
const onSigint = () => cancel(130);
const onSigterm = () => cancel(143);
try {
await withDistArtifactOwnership(repoRoot, async () => {
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
// Imports cannot be interrupted; keep ownership until each pending read joins.
await runBuiltPluginSingletonSmoke(controller.signal);
});
controller.signal.throwIfAborted();
} catch (error) {
if (!controller.signal.aborted || error !== controller.signal.reason) {
throw error;
}
process.exitCode = exitCode;
} finally {
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
fs.rmSync(tempRoot, { recursive: true, force: true });
}