From a421e0be84db80584b4031b479e0ed3f94917f21 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 11:11:31 +0100 Subject: [PATCH] test: fix plugin registry CI contracts --- scripts/run-node.mjs | 25 +++++ src/infra/run-node.test.ts | 32 ++++++ src/plugin-activation-boundary.test.ts | 14 +++ src/plugins/plugin-registry-snapshot.ts | 102 ++++++++++++++++++- src/plugins/plugin-registry.test.ts | 36 +++++++ src/plugins/providers.test.ts | 3 + test/helpers/gateway-e2e-harness.ts | 117 +++++++++++++++++++--- test/helpers/plugins/provider-contract.ts | 1 - 8 files changed, 315 insertions(+), 15 deletions(-) diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index f2594f6484b..f6e083415f9 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -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; diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index c97781d47c3..bc53a2afbec 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -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); + }); + }); }); }); diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index a9d2917aa70..675aa6fa2df 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -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", diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index 5aea077ed30..b4163c32f14 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -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 { diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index f7a9c6e20b0..30ea9842a1a 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -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(); diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index ed20ed7cec5..53e3d43b836 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -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(); diff --git a/test/helpers/gateway-e2e-harness.ts b/test/helpers/gateway-e2e-harness.ts index 4c3da079bfb..d2a870136ab 100644 --- a/test/helpers/gateway-e2e-harness.ts +++ b/test/helpers/gateway-e2e-harness.ts @@ -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 | null = null; + +async function resolveBuiltGatewayEntrypoint(cwd: string): Promise { + 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 { + 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 { + 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 { + return await Promise.race([ + new Promise((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 { const port = await getFreePort(); const hookToken = `token-${name}-${randomUUID()}`; @@ -121,10 +216,12 @@ export async function spawnGatewayInstance(name: string): Promise((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( diff --git a/test/helpers/plugins/provider-contract.ts b/test/helpers/plugins/provider-contract.ts index 5379be8a11b..0cb2eb1d958 100644 --- a/test/helpers/plugins/provider-contract.ts +++ b/test/helpers/plugins/provider-contract.ts @@ -19,7 +19,6 @@ function providerMatchesManifestId(provider: ProviderPlugin, providerId: string) (provider.hookAliases ?? []).includes(providerId) ); } - function resolveProviderContractProvidersFromPublicArtifact( pluginId: string, ): ProviderContractEntry[] | null {