fix: restore ci gates on main

This commit is contained in:
Peter Steinberger
2026-04-28 19:42:50 +01:00
parent bb0461b682
commit f2f34e5f35
11 changed files with 136 additions and 35 deletions

View File

@@ -3,11 +3,16 @@ import os from "node:os";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runExec = vi.hoisted(() => vi.fn());
const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => "/tmp/openclaw"));
vi.mock("../process/exec.js", () => ({
runExec,
}));
vi.mock("openclaw/plugin-sdk/temp-path", () => ({
resolvePreferredOpenClawTmpDir: resolvePreferredOpenClawTmpDirMock,
}));
function mockTrashContainer(...suffixes: string[]) {
let call = 0;
return vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix) => {
@@ -21,6 +26,8 @@ describe("browser trash", () => {
beforeEach(() => {
vi.restoreAllMocks();
runExec.mockReset();
resolvePreferredOpenClawTmpDirMock.mockReset();
resolvePreferredOpenClawTmpDirMock.mockReturnValue("/tmp/openclaw");
vi.spyOn(Date, "now").mockReturnValue(123);
vi.spyOn(os, "homedir").mockReturnValue("/home/test");
vi.spyOn(os, "tmpdir").mockReturnValue("/tmp");
@@ -39,7 +46,7 @@ describe("browser trash", () => {
const cpSync = vi.spyOn(fs, "cpSync");
const rmSync = vi.spyOn(fs, "rmSync");
await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe(
"/home/test/.Trash/demo-123-secure/demo",
);
expect(runExec).not.toHaveBeenCalled();
@@ -48,7 +55,10 @@ describe("browser trash", () => {
mode: 0o700,
});
expect(mkdtempSync).toHaveBeenCalledWith("/home/test/.Trash/demo-123-");
expect(renameSync).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123-secure/demo");
expect(renameSync).toHaveBeenCalledWith(
"/tmp/openclaw/demo",
"/home/test/.Trash/demo-123-secure/demo",
);
expect(cpSync).not.toHaveBeenCalled();
expect(rmSync).not.toHaveBeenCalled();
});
@@ -69,12 +79,12 @@ describe("browser trash", () => {
const mkdtempSync = mockTrashContainer("secure");
const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => undefined);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe(
"/real/home/test/.Trash/demo-123-secure/demo",
);
expect(mkdtempSync).toHaveBeenCalledWith("/real/home/test/.Trash/demo-123-");
expect(renameSync).toHaveBeenCalledWith(
"/tmp/demo",
"/tmp/openclaw/demo",
"/real/home/test/.Trash/demo-123-secure/demo",
);
});
@@ -101,7 +111,7 @@ describe("browser trash", () => {
isSymbolicLink: () => true,
} as fs.Stats);
await expect(movePathToTrash("/tmp/demo")).rejects.toThrow(
await expect(movePathToTrash("/tmp/openclaw/demo")).rejects.toThrow(
"Refusing to use non-directory/symlink trash directory",
);
});
@@ -117,15 +127,22 @@ describe("browser trash", () => {
const cpSync = vi.spyOn(fs, "cpSync").mockImplementation(() => undefined);
const rmSync = vi.spyOn(fs, "rmSync").mockImplementation(() => undefined);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe(
"/home/test/.Trash/demo-123-secure/demo",
);
expect(cpSync).toHaveBeenCalledWith("/tmp/demo", "/home/test/.Trash/demo-123-secure/demo", {
expect(cpSync).toHaveBeenCalledWith(
"/tmp/openclaw/demo",
"/home/test/.Trash/demo-123-secure/demo",
{
recursive: true,
force: false,
errorOnExist: true,
},
);
expect(rmSync).toHaveBeenCalledWith("/tmp/openclaw/demo", {
recursive: true,
force: false,
errorOnExist: true,
});
expect(rmSync).toHaveBeenCalledWith("/tmp/demo", { recursive: true, force: false });
});
it("retries copy fallback when the copy destination is created concurrently", async () => {
@@ -147,12 +164,12 @@ describe("browser trash", () => {
.mockImplementation(() => undefined);
const rmSync = vi.spyOn(fs, "rmSync").mockImplementation(() => undefined);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe(
"/home/test/.Trash/demo-123-second/demo",
);
expect(cpSync).toHaveBeenNthCalledWith(
1,
"/tmp/demo",
"/tmp/openclaw/demo",
"/home/test/.Trash/demo-123-first/demo",
{
recursive: true,
@@ -162,7 +179,7 @@ describe("browser trash", () => {
);
expect(cpSync).toHaveBeenNthCalledWith(
2,
"/tmp/demo",
"/tmp/openclaw/demo",
"/home/test/.Trash/demo-123-second/demo",
{
recursive: true,
@@ -186,17 +203,17 @@ describe("browser trash", () => {
})
.mockImplementation(() => undefined);
await expect(movePathToTrash("/tmp/demo")).resolves.toBe(
await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe(
"/home/test/.Trash/demo-123-second/demo",
);
expect(renameSync).toHaveBeenNthCalledWith(
1,
"/tmp/demo",
"/tmp/openclaw/demo",
"/home/test/.Trash/demo-123-first/demo",
);
expect(renameSync).toHaveBeenNthCalledWith(
2,
"/tmp/demo",
"/tmp/openclaw/demo",
"/home/test/.Trash/demo-123-second/demo",
);
expect(Date.now).toHaveBeenCalledTimes(1);

View File

@@ -1,3 +1,4 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import {
@@ -15,20 +16,32 @@ import {
} from "./test-helpers/temp-plugin-extension-fixtures.js";
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
const tempDirs: string[] = [];
function createTempDir(): string {
return createTempPluginDir(tempDirs, "openclaw-codex-ext-");
}
function createBundledTempDir(): string {
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
return createTempPluginDir(tempDirs, "openclaw-codex-ext-", {
parentDir: path.join(process.cwd(), "dist-runtime", "extensions"),
});
}
afterEach(() => {
clearRuntimeConfigSnapshot();
cleanupTempPluginTestEnvironment(tempDirs, originalBundledPluginsDir);
cleanupTempPluginTestEnvironment(
tempDirs,
originalBundledPluginsDir,
originalDisableBundledPlugins,
);
});
describe("agent tool result middleware", () => {
it("includes plugin-registered middleware and restores it from cache", async () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
@@ -81,7 +94,7 @@ describe("agent tool result middleware", () => {
});
it("rejects middleware when the manifest omits the runtime contract", () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
@@ -158,7 +171,7 @@ describe("agent tool result middleware", () => {
});
it("merges runtimes when a plugin registers the same middleware function twice", () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
@@ -194,7 +207,7 @@ export default { id: "tool-result-middleware", register(api) {
});
it("lazily loads bundled middleware owners from manifest contracts", async () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
@@ -246,7 +259,7 @@ export default { id: "tool-result-middleware", register(api) {
describe("Codex app-server extension factories", () => {
it("includes plugin-registered Codex app-server extension factories and restores them from cache", async () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
@@ -337,7 +350,7 @@ describe("Codex app-server extension factories", () => {
});
it("rejects bundled plugins that omit the Codex app-server extension contract", () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({
@@ -373,7 +386,7 @@ describe("Codex app-server extension factories", () => {
});
it("rejects non-function Codex app-server extension factories from bundled plugins", () => {
const tmp = createTempDir();
const tmp = createBundledTempDir();
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tmp;
writeTempPlugin({

View File

@@ -7,8 +7,14 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
export function createTempPluginDir(tempDirs: string[], prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
export function createTempPluginDir(
tempDirs: string[],
prefix: string,
options?: { parentDir?: string },
): string {
const parentDir = options?.parentDir ?? os.tmpdir();
fs.mkdirSync(parentDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(parentDir, prefix));
tempDirs.push(dir);
return dir;
}
@@ -43,6 +49,7 @@ export function writeTempPlugin(params: {
export function cleanupTempPluginTestEnvironment(
tempDirs: string[],
originalBundledPluginsDir: string | undefined,
originalDisableBundledPlugins?: string,
) {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
@@ -54,6 +61,11 @@ export function cleanupTempPluginTestEnvironment(
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalDisableBundledPlugins === undefined) {
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
} else {
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins;
}
}
export function resetActivePluginRegistryForTest() {

View File

@@ -15,6 +15,20 @@ function makeTempDir() {
return makeTrackedTempDir("openclaw-doctor-plugin-manifests", tempDirs);
}
function makePluginWorkspace() {
const workspaceDir = makeTempDir();
return {
workspaceDir,
pluginsRoot: path.join(workspaceDir, ".openclaw", "extensions"),
env: {
...process.env,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1",
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
},
};
}
function writeManifest(dir: string, manifest: Record<string, unknown>) {
fs.writeFileSync(
path.join(dir, "openclaw.plugin.json"),
@@ -76,7 +90,7 @@ describe("doctor plugin manifest legacy contract repair", () => {
});
it("collects legacy top-level capability keys for migration", () => {
const pluginsRoot = makeTempDir();
const { pluginsRoot } = makePluginWorkspace();
const root = path.join(pluginsRoot, "openai");
fs.mkdirSync(root, { recursive: true });
writePackageJson(root);
@@ -101,7 +115,7 @@ describe("doctor plugin manifest legacy contract repair", () => {
});
it("rewrites legacy top-level capability keys into contracts", async () => {
const pluginsRoot = makeTempDir();
const { pluginsRoot } = makePluginWorkspace();
const root = path.join(pluginsRoot, "openai");
fs.mkdirSync(root, { recursive: true });
writePackageJson(root);
@@ -141,7 +155,7 @@ describe("doctor plugin manifest legacy contract repair", () => {
});
it("ignores non-object contracts payloads when collecting migrations", () => {
const pluginsRoot = makeTempDir();
const { pluginsRoot } = makePluginWorkspace();
const root = path.join(pluginsRoot, "openai");
fs.mkdirSync(root, { recursive: true });
writePackageJson(root);

View File

@@ -83,6 +83,7 @@ function buildLegacyManifestContractMigration(params: {
export function collectLegacyPluginManifestContractMigrations(params?: {
env?: NodeJS.ProcessEnv;
manifestRoots?: string[];
workspaceDir?: string;
}): LegacyManifestContractMigration[] {
const seen = new Set<string>();
const migrations: LegacyManifestContractMigration[] = [];
@@ -114,6 +115,7 @@ export function collectLegacyPluginManifestContractMigrations(params?: {
for (const plugin of loadPluginManifestRegistry({
cache: false,
...(params?.env ? { env: params.env } : {}),
...(params?.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
}).plugins) {
if (seen.has(plugin.manifestPath)) {
continue;
@@ -138,6 +140,7 @@ export function collectLegacyPluginManifestContractMigrations(params?: {
export async function maybeRepairLegacyPluginManifestContracts(params: {
env?: NodeJS.ProcessEnv;
manifestRoots?: string[];
workspaceDir?: string;
runtime: RuntimeEnv;
prompter: DoctorPrompter;
note?: typeof note;
@@ -145,6 +148,7 @@ export async function maybeRepairLegacyPluginManifestContracts(params: {
const migrations = collectLegacyPluginManifestContractMigrations({
...(params.env ? { env: params.env } : {}),
...(params.manifestRoots ? { manifestRoots: params.manifestRoots } : {}),
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
});
if (migrations.length === 0) {
return;

View File

@@ -35,6 +35,7 @@ const GATEWAY_TEST_ENV_KEYS = [
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
"OPENCLAW_SKIP_PROVIDERS",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
] as const;
function nextGatewayId(prefix: string): string {
@@ -110,6 +111,7 @@ async function setupGatewayTempHome(params: { prefix: string; minimalGateway?: b
const workspaceDir = path.join(tempHome, "openclaw");
await fs.mkdir(workspaceDir, { recursive: true });
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = await createEmptyBundledPluginsDir(tempHome);
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
return { envSnapshot, tempHome, workspaceDir };
}
@@ -318,6 +320,7 @@ module.exports = {
"OPENCLAW_SKIP_BROWSER_CONTROL_SERVER",
"OPENCLAW_SKIP_PROVIDERS",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_TEST_MINIMAL_GATEWAY",
]);
@@ -333,6 +336,7 @@ module.exports = {
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-wizard-home-"));
process.env.HOME = tempHome;
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = await createEmptyBundledPluginsDir(tempHome);
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_CONFIG_PATH;

View File

@@ -815,11 +815,18 @@ describe("gateway server misc", () => {
"utf-8",
);
await withEnvAsync({ OPENCLAW_TEST_MINIMAL_GATEWAY: undefined }, async () => {
const autoPort = await getFreePort();
const autoServer = await startGatewayServer(autoPort);
await autoServer.close();
});
await withEnvAsync(
{
OPENCLAW_TEST_MINIMAL_GATEWAY: undefined,
OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"),
},
async () => {
const autoPort = await getFreePort();
const autoServer = await startGatewayServer(autoPort);
await autoServer.close();
},
);
const updated = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
const channels = updated.channels as Record<string, unknown> | undefined;

View File

@@ -70,6 +70,7 @@ const GATEWAY_TEST_ENV_KEYS = [
"OPENCLAW_SKIP_GMAIL_WATCHER",
"OPENCLAW_SKIP_CANVAS_HOST",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_DISABLE_BUNDLED_PLUGINS",
"OPENCLAW_SKIP_CHANNELS",
"OPENCLAW_SKIP_PROVIDERS",
"OPENCLAW_SKIP_CRON",
@@ -235,6 +236,7 @@ function applyGatewaySkipEnv() {
process.env.OPENCLAW_SKIP_PROVIDERS = "1";
process.env.OPENCLAW_SKIP_CRON = "1";
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "1";
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = tempHome
? path.join(tempHome, "openclaw-test-no-bundled-extensions")
: "openclaw-test-no-bundled-extensions";

View File

@@ -203,6 +203,23 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
});
});
it("uses the Node-adjacent POSIX npm shim when npm-cli.js is unavailable", () => {
const execPath = "/opt/node/bin/node";
const npmPath = "/opt/node/bin/npm";
const runner = resolveBundledRuntimeDepsNpmRunner({
env: {},
execPath,
existsSync: (candidate) => candidate === npmPath,
npmArgs: ["install", "acpx@0.5.3"],
platform: "linux",
});
expect(runner).toEqual({
command: npmPath,
args: ["install", "acpx@0.5.3"],
});
});
it("refuses Windows shell fallback when no safe npm executable is available", () => {
expect(() =>
resolveBundledRuntimeDepsNpmRunner({
@@ -222,7 +239,7 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
PATH: "/repo/evil/bin:/usr/bin:/bin",
},
execPath: "/opt/node/bin/node",
existsSync: (candidate) => candidate === "/opt/node/bin/npm",
existsSync: (candidate) => candidate === "/usr/bin/npm",
npmArgs: ["install"],
platform: "linux",
}),

View File

@@ -1325,6 +1325,14 @@ export function resolveBundledRuntimeDepsNpmRunner(params: {
throw new Error("Unable to resolve a safe npm executable on Windows");
}
const npmExePath = pathImpl.resolve(nodeDir, "npm");
if (existsSync(npmExePath)) {
return {
command: npmExePath,
args: params.npmArgs,
};
}
throw new Error("Unable to resolve a safe npm executable");
}
type BundledPluginRuntimeDepsManifest = {

View File

@@ -45,6 +45,7 @@ function collectBundledChannelOwnerPluginIds(params: {
config: OpenClawConfig;
channelIds: readonly string[];
env: NodeJS.ProcessEnv;
bundledPluginsDir?: string;
}): string[] {
const plugins = normalizePluginsConfig(params.config.plugins);
const channelIds = new Set(
@@ -55,7 +56,7 @@ function collectBundledChannelOwnerPluginIds(params: {
if (channelIds.size === 0) {
return [];
}
const bundledDir = resolveBundledPluginsDir(params.env);
const bundledDir = params.bundledPluginsDir ?? resolveBundledPluginsDir(params.env);
if (!bundledDir) {
return [];
}
@@ -126,6 +127,7 @@ export function resolveEffectivePluginIds(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
workspaceDir?: string;
bundledPluginsDir?: string;
}): string[] {
const autoEnabled = applyPluginAutoEnable({
config: params.config,
@@ -150,6 +152,7 @@ export function resolveEffectivePluginIds(params: {
config: effectiveConfig,
channelIds: configuredChannelIds,
env: params.env,
...(params.bundledPluginsDir ? { bundledPluginsDir: params.bundledPluginsDir } : {}),
})) {
ids.add(pluginId);
}