test: fix plugin registry CI contracts

This commit is contained in:
Peter Steinberger
2026-04-27 11:11:31 +01:00
parent a0aedea63d
commit a421e0be84
8 changed files with 315 additions and 15 deletions

View File

@@ -610,8 +610,33 @@ const closeRunNodeOutputTee = async (deps, exitCode) => {
return exitCode;
};
const readBuildLockOwnerPid = (deps, lockDir) => {
try {
const raw = deps.fs.readFileSync(path.join(lockDir, "owner.json"), "utf8");
const parsed = JSON.parse(raw);
const pid = Number(parsed?.pid);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
};
const isBuildLockOwnerDead = (deps, pid) => {
try {
deps.process.kill(pid, 0);
return false;
} catch (error) {
return error?.code === "ESRCH";
}
};
const removeStaleBuildLock = (deps, lockDir, staleMs) => {
try {
const ownerPid = readBuildLockOwnerPid(deps, lockDir);
if (ownerPid !== null && isBuildLockOwnerDead(deps, ownerPid)) {
deps.fs.rmSync(lockDir, { recursive: true, force: true });
return true;
}
const stats = deps.fs.statSync(lockDir);
if (Date.now() - stats.mtimeMs < staleMs) {
return false;

View File

@@ -1350,5 +1350,37 @@ describe("run-node script", () => {
expect(fakeProcess.listenerCount("exit")).toBe(0);
});
});
it("removes a lock left by a dead wrapper process without waiting for age-out", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
const lockDir = path.join(tmp, ".artifacts", "run-node-build.lock");
await fs.mkdir(lockDir, { recursive: true });
await fs.writeFile(
path.join(lockDir, "owner.json"),
JSON.stringify({ pid: 987654, args: ["gateway"] }),
"utf-8",
);
const fakeProcess = Object.assign(createFakeProcess(), {
kill: vi.fn((pid: number, signal?: NodeJS.Signals | number) => {
if (pid === 987654 && signal === 0) {
const err = new Error("missing process") as Error & { code: string };
err.code = "ESRCH";
throw err;
}
return true;
}),
}) as unknown as NodeJS.Process;
const release = await acquireRunNodeBuildLock(lockDeps(tmp, fakeProcess));
expect(fakeProcess.kill).toHaveBeenCalledWith(987654, 0);
expect(JSON.parse(await fs.readFile(path.join(lockDir, "owner.json"), "utf-8")).pid).toBe(
4242,
);
release();
expect(fsSync.existsSync(lockDir)).toBe(false);
});
});
});
});

View File

@@ -34,6 +34,20 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
slack: ["SLACK_BOT_TOKEN"],
telegram: ["TELEGRAM_BOT_TOKEN"],
},
modelIdNormalization: {
providers: {
google: {
aliases: {
"gemini-3.1-pro": "gemini-3.1-pro-preview",
},
},
xai: {
aliases: {
"grok-4-fast-reasoning": "grok-4-fast",
},
},
},
},
skills: [],
hooks: [],
origin: "bundled",

View File

@@ -1,7 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { resolveCompatibilityHostVersion } from "../version.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import {
inspectPersistedInstalledPluginIndex,
readPersistedInstalledPluginIndexSync,
resolveInstalledPluginIndexStorePath,
refreshPersistedInstalledPluginIndex,
type InstalledPluginIndexStoreInspection,
type InstalledPluginIndexStoreOptions,
@@ -18,6 +22,7 @@ import {
type LoadInstalledPluginIndexParams,
type RefreshInstalledPluginIndexParams,
} from "./installed-plugin-index.js";
import { resolvePluginCacheInputs } from "./roots.js";
export type PluginRegistrySnapshot = InstalledPluginIndex;
export type PluginRegistryRecord = InstalledPluginIndexRecord;
@@ -41,6 +46,16 @@ export type PluginRegistrySnapshotResult = {
diagnostics: readonly PluginRegistrySnapshotDiagnostic[];
};
const DERIVED_SNAPSHOT_CACHE_MS = 1000;
const derivedSnapshotCache = new Map<
string,
{ expiresAt: number; result: PluginRegistrySnapshotResult }
>();
export function clearPluginRegistrySnapshotCache(): void {
derivedSnapshotCache.clear();
}
export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY";
function formatDeprecatedPersistedRegistryDisableWarning(): string {
@@ -76,6 +91,68 @@ function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean {
});
}
function resolveComparablePath(filePath: string): string {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
function isPathInsideOrEqual(childPath: string, parentPath: string): boolean {
const relative = path.relative(
resolveComparablePath(parentPath),
resolveComparablePath(childPath),
);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function hasMismatchedPersistedBundledPluginRoot(
index: InstalledPluginIndex,
env: NodeJS.ProcessEnv,
): boolean {
const bundledPluginsDir = resolveBundledPluginsDir(env);
if (!bundledPluginsDir) {
return false;
}
return index.plugins.some(
(plugin) =>
plugin.origin === "bundled" && !isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir),
);
}
function resolveDerivedSnapshotCacheKey(
params: LoadPluginRegistryParams,
env: NodeJS.ProcessEnv,
): string | null {
if (
params.cache === false ||
params.preferPersisted === false ||
params.config ||
params.workspaceDir ||
params.stateDir ||
params.filePath ||
params.pluginIndexFilePath ||
params.installRecords ||
params.candidates ||
params.diagnostics ||
params.now
) {
return null;
}
const { roots, loadPaths } = resolvePluginCacheInputs({ env });
return JSON.stringify({
persistedStore: resolveInstalledPluginIndexStorePath({ env }),
roots,
loadPaths,
hostContractVersion: resolveCompatibilityHostVersion(env),
disablePersisted: env[DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV] ?? "",
disableBundled: env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ?? "",
vitest: env.VITEST ?? "",
});
}
export function loadPluginRegistrySnapshotWithMetadata(
params: LoadPluginRegistryParams = {},
): PluginRegistrySnapshotResult {
@@ -93,6 +170,15 @@ export function loadPluginRegistrySnapshotWithMetadata(
const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV);
const persistedReadsEnabled = !disabledByCaller && !disabledByEnv;
const persistedInstallRecordReadsEnabled = !disabledByEnv;
const derivedCacheKey = persistedReadsEnabled
? resolveDerivedSnapshotCacheKey(params, env)
: null;
if (derivedCacheKey) {
const cached = derivedSnapshotCache.get(derivedCacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.result;
}
}
let persistedIndex: InstalledPluginIndex | null = null;
if (persistedInstallRecordReadsEnabled) {
persistedIndex = readPersistedInstalledPluginIndexSync(params);
@@ -114,6 +200,13 @@ export function loadPluginRegistrySnapshotWithMetadata(
message:
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env)) {
diagnostics.push({
level: "warn",
code: "persisted-registry-stale-source",
message:
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
});
} else {
return {
snapshot: persistedIndex,
@@ -138,7 +231,7 @@ export function loadPluginRegistrySnapshotWithMetadata(
});
}
return {
const result: PluginRegistrySnapshotResult = {
snapshot: loadInstalledPluginIndex({
...params,
installRecords:
@@ -148,6 +241,13 @@ export function loadPluginRegistrySnapshotWithMetadata(
source: "derived",
diagnostics,
};
if (derivedCacheKey) {
derivedSnapshotCache.set(derivedCacheKey, {
expiresAt: Date.now() + DERIVED_SNAPSHOT_CACHE_MS,
result,
});
}
return result;
}
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {

View File

@@ -428,6 +428,42 @@ describe("plugin registry facade", () => {
]);
});
it("falls back to the derived registry when persisted bundled roots point at another checkout", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
const staleBundledRootDir = makeTempDir();
const candidate = createCandidate(rootDir);
createCandidate(staleBundledRootDir);
await writePersistedInstalledPluginIndex(
createIndex("persisted", {
plugins: [
{
...createIndex("persisted").plugins[0],
manifestPath: path.join(staleBundledRootDir, "openclaw.plugin.json"),
source: path.join(staleBundledRootDir, "index.ts"),
rootDir: staleBundledRootDir,
origin: "bundled",
},
],
}),
{ stateDir },
);
const result = loadPluginRegistrySnapshotWithMetadata({
stateDir,
candidates: [candidate],
env: hermeticEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: rootDir }),
});
expect(result.source).toBe("derived");
expect(result.diagnostics).toEqual([
expect.objectContaining({ code: "persisted-registry-stale-source" }),
]);
expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([
"demo",
]);
});
it("falls back to the derived registry when persisted policy is stale", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();

View File

@@ -29,6 +29,7 @@ let resolveDiscoveredProviderPluginIds: typeof import("./providers.js").resolveD
let resolveDiscoverableProviderOwnerPluginIds: typeof import("./providers.js").resolveDiscoverableProviderOwnerPluginIds;
let resolvePluginProviders: typeof import("./providers.runtime.js").resolvePluginProviders;
let setActivePluginRegistry: SetActivePluginRegistry;
let clearPluginRegistrySnapshotCache: typeof import("./plugin-registry-snapshot.js").clearPluginRegistrySnapshotCache;
function createManifestProviderPlugin(params: {
id: string;
@@ -309,6 +310,7 @@ describe("resolvePluginProviders", () => {
} = await import("./providers.js"));
({ resolvePluginProviders } = await import("./providers.runtime.js"));
({ setActivePluginRegistry } = await import("./runtime.js"));
({ clearPluginRegistrySnapshotCache } = await import("./plugin-registry-snapshot.js"));
});
it("maps cli backend ids to owning plugin ids via manifests", () => {
@@ -319,6 +321,7 @@ describe("resolvePluginProviders", () => {
});
beforeEach(() => {
clearPluginRegistrySnapshotCache();
setActivePluginRegistry(createEmptyPluginRegistry());
resolveRuntimePluginRegistryMock.mockReset();
loadOpenClawPluginsMock.mockReset();

View File

@@ -39,6 +39,77 @@ const GATEWAY_STOP_TIMEOUT_MS = 1_500;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 2_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 4_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const GATEWAY_HOME_REMOVE_RETRIES = 5;
const GATEWAY_HOME_REMOVE_RETRY_DELAY_MS = 100;
const GATEWAY_ENTRYPOINT_PREPARE_TIMEOUT_MS = 120_000;
let gatewayEntrypointPromise: Promise<string[]> | null = null;
async function resolveBuiltGatewayEntrypoint(cwd: string): Promise<string[] | null> {
const buildStampPath = path.join(cwd, "dist", ".buildstamp");
const runtimePostBuildStampPath = path.join(cwd, "dist", ".runtime-postbuildstamp");
for (const entrypoint of ["dist/index.js", "dist/index.mjs"]) {
try {
await Promise.all([
fs.access(path.join(cwd, entrypoint)),
fs.access(buildStampPath),
fs.access(runtimePostBuildStampPath),
]);
return [entrypoint];
} catch {
// try the next built entrypoint
}
}
return null;
}
async function prepareGatewayEntrypoint(cwd: string): Promise<string[]> {
const builtEntrypoint = await resolveBuiltGatewayEntrypoint(cwd);
if (builtEntrypoint) {
return builtEntrypoint;
}
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn("node", ["scripts/run-node.mjs", "--help"], {
cwd,
env: { ...process.env, VITEST: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (d) => stdout.push(String(d)));
child.stderr?.on("data", (d) => stderr.push(String(d)));
const completed = await Promise.race([
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => resolve({ code, signal }));
}),
sleep(GATEWAY_ENTRYPOINT_PREPARE_TIMEOUT_MS).then(() => null),
]);
if (completed === null) {
child.kill("SIGKILL");
throw new Error(
`timeout preparing gateway entrypoint\n--- stdout ---\n${stdout.join("")}\n--- stderr ---\n${stderr.join("")}`,
);
}
if (completed.code !== 0) {
throw new Error(
`failed preparing gateway entrypoint (code=${String(completed.code)} signal=${String(
completed.signal,
)})\n--- stdout ---\n${stdout.join("")}\n--- stderr ---\n${stderr.join("")}`,
);
}
return (await resolveBuiltGatewayEntrypoint(cwd)) ?? ["scripts/run-node.mjs"];
}
async function resolveGatewayEntrypoint(cwd: string): Promise<string[]> {
gatewayEntrypointPromise ??= prepareGatewayEntrypoint(cwd);
return await gatewayEntrypointPromise;
}
const getFreePort = async () => {
const srv = net.createServer();
@@ -97,6 +168,30 @@ async function waitForPortOpen(
);
}
async function waitForGatewayExit(
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<boolean> {
return await Promise.race([
new Promise<boolean>((resolve) => {
if (child.exitCode !== null || child.signalCode !== null) {
return resolve(true);
}
child.once("exit", () => resolve(true));
}),
sleep(timeoutMs).then(() => false),
]);
}
async function removeGatewayHome(homeDir: string) {
await fs.rm(homeDir, {
recursive: true,
force: true,
maxRetries: GATEWAY_HOME_REMOVE_RETRIES,
retryDelay: GATEWAY_HOME_REMOVE_RETRY_DELAY_MS,
});
}
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const port = await getFreePort();
const hookToken = `token-${name}-${randomUUID()}`;
@@ -121,10 +216,12 @@ export async function spawnGatewayInstance(name: string): Promise<GatewayInstanc
let child: ChildProcessWithoutNullStreams | null = null;
try {
const cwd = process.cwd();
const entrypoint = await resolveGatewayEntrypoint(cwd);
child = spawn(
"node",
[
"dist/index.js",
...entrypoint,
"gateway",
"--port",
String(port),
@@ -133,7 +230,7 @@ export async function spawnGatewayInstance(name: string): Promise<GatewayInstanc
"--allow-unconfigured",
],
{
cwd: process.cwd(),
cwd,
env: {
...process.env,
HOME: homeDir,
@@ -180,8 +277,9 @@ export async function spawnGatewayInstance(name: string): Promise<GatewayInstanc
} catch {
// ignore
}
await waitForGatewayExit(child, GATEWAY_STOP_TIMEOUT_MS);
}
await fs.rm(homeDir, { recursive: true, force: true });
await removeGatewayHome(homeDir);
throw err;
}
}
@@ -194,23 +292,16 @@ export async function stopGatewayInstance(inst: GatewayInstance) {
// ignore
}
}
const exited = await Promise.race([
new Promise<boolean>((resolve) => {
if (inst.child.exitCode !== null) {
return resolve(true);
}
inst.child.once("exit", () => resolve(true));
}),
sleep(GATEWAY_STOP_TIMEOUT_MS).then(() => false),
]);
let exited = await waitForGatewayExit(inst.child, GATEWAY_STOP_TIMEOUT_MS);
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGKILL");
} catch {
// ignore
}
await waitForGatewayExit(inst.child, GATEWAY_STOP_TIMEOUT_MS);
}
await fs.rm(inst.homeDir, { recursive: true, force: true });
await removeGatewayHome(inst.homeDir);
}
export async function postJson(

View File

@@ -19,7 +19,6 @@ function providerMatchesManifestId(provider: ProviderPlugin, providerId: string)
(provider.hookAliases ?? []).includes(providerId)
);
}
function resolveProviderContractProvidersFromPublicArtifact(
pluginId: string,
): ProviderContractEntry[] | null {