mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:20:43 +00:00
test: fix plugin registry CI contracts
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -19,7 +19,6 @@ function providerMatchesManifestId(provider: ProviderPlugin, providerId: string)
|
||||
(provider.hookAliases ?? []).includes(providerId)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProviderContractProvidersFromPublicArtifact(
|
||||
pluginId: string,
|
||||
): ProviderContractEntry[] | null {
|
||||
|
||||
Reference in New Issue
Block a user