fix: stabilize channel configured probes

This commit is contained in:
Peter Steinberger
2026-05-02 00:52:54 +01:00
parent 8d54b898fb
commit b732f58285
5 changed files with 60 additions and 50 deletions

View File

@@ -11,23 +11,17 @@ import {
listPotentialConfiguredChannelIds,
} from "./config-presence.js";
vi.mock("./plugins/bundled-ids.js", () => ({
listBundledChannelPluginIds: () => ["matrix"],
}));
vi.mock("../channels/plugins/persisted-auth-state.js", () => ({
listBundledChannelIdsWithPersistedAuthState: () => ["matrix"],
hasBundledChannelPersistedAuthState: ({
channelId,
env,
}: {
channelId: string;
env?: NodeJS.ProcessEnv;
}) => channelId === "matrix" && env?.OPENCLAW_STATE_DIR?.includes("persisted-matrix"),
}));
const tempDirs: string[] = [];
const matrixPresenceOptions = {
channelIds: ["matrix"],
persistedAuthStateProbe: {
listChannelIds: () => ["matrix"],
hasState: ({ channelId, env }: { channelId: string; env?: NodeJS.ProcessEnv }) =>
channelId === "matrix" && Boolean(env?.OPENCLAW_STATE_DIR?.includes("persisted-matrix")),
},
};
function makeTempStateDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-config-presence-"));
tempDirs.push(dir);
@@ -41,7 +35,7 @@ function expectPotentialConfiguredChannelCase(params: {
expectedConfigured: boolean;
options?: Parameters<typeof listPotentialConfiguredChannelIds>[2];
}) {
const options = params.options ?? {};
const options = params.options ?? matrixPresenceOptions;
expect(listPotentialConfiguredChannelIds(params.cfg, params.env, options)).toEqual(
params.expectedIds,
);

View File

@@ -14,6 +14,7 @@ import { listBundledChannelPluginIds } from "./plugins/bundled-ids.js";
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
type ChannelPresenceOptions = {
channelIds?: readonly string[];
includePersistedAuthState?: boolean;
persistedAuthStateProbe?: {
listChannelIds: () => readonly string[];
@@ -120,7 +121,7 @@ export function listPotentialConfiguredChannelPresenceSignals(
signals.push({ channelId, source });
};
const configuredChannelIds = new Set<string>();
const channelIds = listBundledChannelPluginIds(env);
const channelIds = options.channelIds ?? listBundledChannelPluginIds(env);
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
const channels = isRecord(cfg.channels) ? cfg.channels : null;
if (channels) {
@@ -164,7 +165,7 @@ function hasEnvConfiguredChannel(
env: NodeJS.ProcessEnv,
options: ChannelPresenceOptions = {},
): boolean {
const channelIds = listBundledChannelPluginIds(env);
const channelIds = options.channelIds ?? listBundledChannelPluginIds(env);
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
for (const [key, value] of Object.entries(env)) {
if (!hasNonEmptyString(value)) {

View File

@@ -12,7 +12,9 @@ afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("jiti");
});
function createTempDir(): string {
@@ -61,29 +63,39 @@ describe("channel plugin module loader helpers", () => {
expect(createJiti).not.toHaveBeenCalled();
});
it("rejects TypeScript modules without creating Jiti", async () => {
const createJiti = vi.fn(() => {
throw new Error("channel module loader must not create jiti");
});
it("loads TypeScript channel plugin modules through Jiti when no native hook exists", async () => {
const loadWithJiti = vi.fn((target: string) => ({
loadedBy: "jiti",
target,
}));
const createJiti = vi.fn(() => loadWithJiti);
vi.resetModules();
vi.doMock("jiti", () => ({
createJiti,
}));
const loaderModule = await importFreshModule<typeof import("./module-loader.js")>(
import.meta.url,
"./module-loader.js?scope=source-ts-native-hook",
"./module-loader.js?scope=source-ts-jiti-fallback",
);
const rootDir = createTempDir();
const modulePath = path.join(rootDir, "extensions", "demo", "index.ts");
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
fs.writeFileSync(modulePath, "export const ok = true;\n", "utf8");
expect(() =>
expect(
loaderModule.loadChannelPluginModule({
modulePath,
rootDir,
}),
).toThrow(/must be built JavaScript/u);
expect(createJiti).not.toHaveBeenCalled();
).toEqual({
loadedBy: "jiti",
target: modulePath,
});
expect(createJiti).toHaveBeenCalledOnce();
expect(createJiti).toHaveBeenCalledWith(
expect.stringContaining("module-loader.ts"),
expect.objectContaining({ tryNative: false }),
);
expect(loadWithJiti).toHaveBeenCalledWith(modulePath);
});
});

View File

@@ -2,10 +2,15 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { openBoundaryFileSync } from "../../infra/boundary-file-read.js";
import {
getCachedPluginJitiLoader,
type PluginJitiLoaderCache,
} from "../../plugins/jiti-loader-cache.js";
import { isJavaScriptModulePath } from "../../plugins/native-module-require.js";
const nodeRequire = createRequire(import.meta.url);
const SOURCE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
const jitiLoaders: PluginJitiLoaderCache = new Map();
function hasNativeSourceRequireHook(modulePath: string): boolean {
const extension = path.extname(modulePath).toLowerCase();
@@ -15,13 +20,35 @@ function hasNativeSourceRequireHook(modulePath: string): boolean {
);
}
function isSourceModulePath(modulePath: string): boolean {
return SOURCE_MODULE_EXTENSIONS.has(path.extname(modulePath).toLowerCase());
}
function loadModuleWithJiti(modulePath: string): unknown {
const loadWithJiti = getCachedPluginJitiLoader({
cache: jitiLoaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: import.meta.url,
tryNative: false,
cacheScopeKey: "channel-plugin-module-loader",
});
return loadWithJiti(modulePath);
}
function loadModule(modulePath: string): unknown {
if (!isJavaScriptModulePath(modulePath) && !hasNativeSourceRequireHook(modulePath)) {
if (isSourceModulePath(modulePath)) {
return loadModuleWithJiti(modulePath);
}
throw new Error(`channel plugin module must be built JavaScript: ${modulePath}`);
}
try {
return nodeRequire(modulePath);
} catch (error) {
if (isSourceModulePath(modulePath)) {
return loadModuleWithJiti(modulePath);
}
throw new Error(`failed to load channel plugin module with native require: ${modulePath}`, {
cause: error,
});

View File

@@ -1,30 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { isChannelConfigured } from "./channel-configured.js";
vi.mock("../channels/plugins/configured-state.js", () => ({
hasBundledChannelConfiguredState: ({
channelId,
env,
}: {
channelId: string;
env?: NodeJS.ProcessEnv;
}) => {
if (channelId === "telegram") {
return Boolean(env?.TELEGRAM_BOT_TOKEN);
}
if (channelId === "discord") {
return Boolean(env?.DISCORD_BOT_TOKEN);
}
if (channelId === "slack") {
return Boolean(env?.SLACK_BOT_TOKEN);
}
if (channelId === "irc") {
return Boolean(env?.IRC_HOST && env?.IRC_NICK);
}
return false;
},
}));
vi.mock("../channels/plugins/bootstrap-registry.js", () => ({
getBootstrapChannelPlugin: () => undefined,
}));