refactor: add metadata-first channel configured-state probes

This commit is contained in:
Peter Steinberger
2026-04-06 00:59:43 +01:00
parent ad6c584ce7
commit 6cdf5a43f2
28 changed files with 493 additions and 193 deletions

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
hasBundledChannelConfiguredState,
listBundledChannelIdsWithConfiguredState,
} from "./configured-state.js";
describe("bundled channel configured-state metadata", () => {
it("lists the shipped metadata-first configured-state channels", () => {
expect(listBundledChannelIdsWithConfiguredState()).toEqual(
expect.arrayContaining(["discord", "irc", "slack", "telegram"]),
);
});
it("resolves Discord, Slack, Telegram, and IRC env probes without full plugin loads", () => {
expect(
hasBundledChannelConfiguredState({
channelId: "discord",
cfg: {},
env: { DISCORD_BOT_TOKEN: "token" },
}),
).toBe(true);
expect(
hasBundledChannelConfiguredState({
channelId: "slack",
cfg: {},
env: { SLACK_BOT_TOKEN: "xoxb-test" },
}),
).toBe(true);
expect(
hasBundledChannelConfiguredState({
channelId: "telegram",
cfg: {},
env: { TELEGRAM_BOT_TOKEN: "token" },
}),
).toBe(true);
expect(
hasBundledChannelConfiguredState({
channelId: "irc",
cfg: {},
env: { IRC_HOST: "irc.example.com", IRC_NICK: "openclaw" },
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,22 @@
import type { OpenClawConfig } from "../../config/config.js";
import {
hasBundledChannelPackageState,
listBundledChannelIdsForPackageState,
} from "./package-state-probes.js";
export function listBundledChannelIdsWithConfiguredState(): string[] {
return listBundledChannelIdsForPackageState("configuredState");
}
export function hasBundledChannelConfiguredState(params: {
channelId: string;
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
return hasBundledChannelPackageState({
metadataKey: "configuredState",
channelId: params.channelId,
cfg: params.cfg,
env: params.env,
});
}

View File

@@ -0,0 +1,211 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { createJiti } from "jiti";
import type { OpenClawConfig } from "../../config/config.js";
import { openBoundaryFileSync } from "../../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
listChannelCatalogEntries,
type PluginChannelCatalogEntry,
} from "../../plugins/channel-catalog-registry.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
shouldPreferNativeJiti,
} from "../../plugins/sdk-alias.js";
type ChannelPackageStateChecker = (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => boolean;
type ChannelPackageStateMetadata = {
specifier?: string;
exportName?: string;
};
export type ChannelPackageStateMetadataKey = "configuredState" | "persistedAuthState";
type ChannelPackageStateRegistry = {
catalog: PluginChannelCatalogEntry[];
entriesById: Map<string, PluginChannelCatalogEntry>;
checkerCache: Map<string, ChannelPackageStateChecker | null>;
};
const log = createSubsystemLogger("channels");
const nodeRequire = createRequire(import.meta.url);
const registryCache = new Map<ChannelPackageStateMetadataKey, ChannelPackageStateRegistry>();
function resolveChannelPackageStateMetadata(
entry: PluginChannelCatalogEntry,
metadataKey: ChannelPackageStateMetadataKey,
): ChannelPackageStateMetadata | null {
const metadata = entry.channel[metadataKey];
if (!metadata || typeof metadata !== "object") {
return null;
}
const specifier = typeof metadata.specifier === "string" ? metadata.specifier.trim() : "";
const exportName = typeof metadata.exportName === "string" ? metadata.exportName.trim() : "";
if (!specifier || !exportName) {
return null;
}
return { specifier, exportName };
}
function createModuleLoader() {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
return (modulePath: string) => {
const tryNative =
shouldPreferNativeJiti(modulePath) || modulePath.includes(`${path.sep}dist${path.sep}`);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
}
const loadModule = createModuleLoader();
function getChannelPackageStateRegistry(
metadataKey: ChannelPackageStateMetadataKey,
): ChannelPackageStateRegistry {
const cached = registryCache.get(metadataKey);
if (cached) {
return cached;
}
const catalog = listChannelCatalogEntries({ origin: "bundled" }).filter((entry) =>
Boolean(resolveChannelPackageStateMetadata(entry, metadataKey)),
);
const registry = {
catalog,
entriesById: new Map(catalog.map((entry) => [entry.pluginId, entry] as const)),
checkerCache: new Map(),
} satisfies ChannelPackageStateRegistry;
registryCache.set(metadataKey, registry);
return registry;
}
function resolveModuleCandidates(entry: PluginChannelCatalogEntry, specifier: string): string[] {
const normalizedSpecifier = specifier.replace(/\\/g, "/");
const resolvedPath = path.resolve(entry.rootDir, normalizedSpecifier);
const ext = path.extname(resolvedPath);
if (ext) {
return [resolvedPath];
}
return [
resolvedPath,
`${resolvedPath}.ts`,
`${resolvedPath}.js`,
`${resolvedPath}.mjs`,
`${resolvedPath}.cjs`,
];
}
function resolveExistingModulePath(entry: PluginChannelCatalogEntry, specifier: string): string {
for (const candidate of resolveModuleCandidates(entry, specifier)) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return path.resolve(entry.rootDir, specifier);
}
function loadChannelPackageStateModule(modulePath: string, rootDir: string): unknown {
const opened = openBoundaryFileSync({
absolutePath: modulePath,
rootPath: rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: false,
skipLexicalRootCheck: true,
});
if (!opened.ok) {
throw new Error("plugin package-state module escapes plugin root or fails alias checks");
}
const safePath = opened.path;
fs.closeSync(opened.fd);
if (
process.platform === "win32" &&
[".js", ".mjs", ".cjs"].includes(path.extname(safePath).toLowerCase())
) {
try {
return nodeRequire(safePath);
} catch {
// Fall back to Jiti when native require cannot load the target.
}
}
return loadModule(safePath)(safePath);
}
function resolveChannelPackageStateChecker(params: {
entry: PluginChannelCatalogEntry;
metadataKey: ChannelPackageStateMetadataKey;
}): ChannelPackageStateChecker | null {
const registry = getChannelPackageStateRegistry(params.metadataKey);
const cached = registry.checkerCache.get(params.entry.pluginId);
if (cached !== undefined) {
return cached;
}
const metadata = resolveChannelPackageStateMetadata(params.entry, params.metadataKey);
if (!metadata) {
registry.checkerCache.set(params.entry.pluginId, null);
return null;
}
try {
const moduleExport = loadChannelPackageStateModule(
resolveExistingModulePath(params.entry, metadata.specifier!),
params.entry.rootDir,
) as Record<string, unknown>;
const checker = moduleExport[metadata.exportName!] as ChannelPackageStateChecker | undefined;
if (typeof checker !== "function") {
throw new Error(`missing ${params.metadataKey} export ${metadata.exportName}`);
}
registry.checkerCache.set(params.entry.pluginId, checker);
return checker;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
log.warn(
`[channels] failed to load ${params.metadataKey} checker for ${params.entry.pluginId}: ${detail}`,
);
registry.checkerCache.set(params.entry.pluginId, null);
return null;
}
}
export function listBundledChannelIdsForPackageState(
metadataKey: ChannelPackageStateMetadataKey,
): string[] {
return getChannelPackageStateRegistry(metadataKey).catalog.map((entry) => entry.pluginId);
}
export function hasBundledChannelPackageState(params: {
metadataKey: ChannelPackageStateMetadataKey;
channelId: string;
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const registry = getChannelPackageStateRegistry(params.metadataKey);
const entry = registry.entriesById.get(params.channelId);
if (!entry) {
return false;
}
const checker = resolveChannelPackageStateChecker({
entry,
metadataKey: params.metadataKey,
});
return checker ? Boolean(checker({ cfg: params.cfg, env: params.env })) : false;
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
hasBundledChannelPersistedAuthState,
listBundledChannelIdsWithPersistedAuthState,
} from "./persisted-auth-state.js";
describe("bundled channel persisted-auth metadata", () => {
it("lists shipped persisted-auth metadata channels", () => {
expect(listBundledChannelIdsWithPersistedAuthState()).toContain("whatsapp");
});
it("does not report auth state for channels without bundled metadata", () => {
expect(
hasBundledChannelPersistedAuthState({
channelId: "discord",
cfg: {},
env: {},
}),
).toBe(false);
});
});

View File

@@ -1,167 +1,11 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { createJiti } from "jiti";
import type { OpenClawConfig } from "../../config/config.js";
import { openBoundaryFileSync } from "../../infra/boundary-file-read.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
listChannelCatalogEntries,
type PluginChannelCatalogEntry,
} from "../../plugins/channel-catalog-registry.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
shouldPreferNativeJiti,
} from "../../plugins/sdk-alias.js";
type PersistedAuthStateChecker = (params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => boolean;
type PersistedAuthStateMetadata = {
specifier?: string;
exportName?: string;
};
const log = createSubsystemLogger("channels");
const nodeRequire = createRequire(import.meta.url);
const persistedAuthStateCatalog = listChannelCatalogEntries({ origin: "bundled" }).filter((entry) =>
Boolean(resolvePersistedAuthStateMetadata(entry)),
);
const persistedAuthStateEntriesById = new Map(
persistedAuthStateCatalog.map((entry) => [entry.pluginId, entry] as const),
);
const persistedAuthStateCheckerCache = new Map<string, PersistedAuthStateChecker | null>();
function resolvePersistedAuthStateMetadata(
entry: PluginChannelCatalogEntry,
): PersistedAuthStateMetadata | null {
const metadata = entry.channel.persistedAuthState;
if (!metadata || typeof metadata !== "object") {
return null;
}
const specifier = typeof metadata.specifier === "string" ? metadata.specifier.trim() : "";
const exportName = typeof metadata.exportName === "string" ? metadata.exportName.trim() : "";
if (!specifier || !exportName) {
return null;
}
return { specifier, exportName };
}
function createModuleLoader() {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
return (modulePath: string) => {
const tryNative =
shouldPreferNativeJiti(modulePath) || modulePath.includes(`${path.sep}dist${path.sep}`);
const aliasMap = buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url);
const cacheKey = JSON.stringify({
tryNative,
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
});
const cached = jitiLoaders.get(cacheKey);
if (cached) {
return cached;
}
const loader = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
jitiLoaders.set(cacheKey, loader);
return loader;
};
}
const loadModule = createModuleLoader();
function resolveModuleCandidates(entry: PluginChannelCatalogEntry, specifier: string): string[] {
const normalizedSpecifier = specifier.replace(/\\/g, "/");
const resolvedPath = path.resolve(entry.rootDir, normalizedSpecifier);
const ext = path.extname(resolvedPath);
if (ext) {
return [resolvedPath];
}
return [
resolvedPath,
`${resolvedPath}.ts`,
`${resolvedPath}.js`,
`${resolvedPath}.mjs`,
`${resolvedPath}.cjs`,
];
}
function resolveExistingModulePath(entry: PluginChannelCatalogEntry, specifier: string): string {
for (const candidate of resolveModuleCandidates(entry, specifier)) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return path.resolve(entry.rootDir, specifier);
}
function loadPersistedAuthStateModule(modulePath: string, rootDir: string): unknown {
const opened = openBoundaryFileSync({
absolutePath: modulePath,
rootPath: rootDir,
boundaryLabel: "plugin root",
rejectHardlinks: false,
skipLexicalRootCheck: true,
});
if (!opened.ok) {
throw new Error("plugin persisted-auth module escapes plugin root or fails alias checks");
}
const safePath = opened.path;
fs.closeSync(opened.fd);
if (
process.platform === "win32" &&
[".js", ".mjs", ".cjs"].includes(path.extname(safePath).toLowerCase())
) {
try {
return nodeRequire(safePath);
} catch {
// Fall back to Jiti when native require cannot load the target.
}
}
return loadModule(safePath)(safePath);
}
function resolvePersistedAuthStateChecker(
entry: PluginChannelCatalogEntry,
): PersistedAuthStateChecker | null {
const cached = persistedAuthStateCheckerCache.get(entry.pluginId);
if (cached !== undefined) {
return cached;
}
const metadata = resolvePersistedAuthStateMetadata(entry);
if (!metadata) {
persistedAuthStateCheckerCache.set(entry.pluginId, null);
return null;
}
try {
const moduleExport = loadPersistedAuthStateModule(
resolveExistingModulePath(entry, metadata.specifier!),
entry.rootDir,
) as Record<string, unknown>;
const checker = moduleExport[metadata.exportName!] as PersistedAuthStateChecker | undefined;
if (typeof checker !== "function") {
throw new Error(`missing persisted auth export ${metadata.exportName}`);
}
persistedAuthStateCheckerCache.set(entry.pluginId, checker);
return checker;
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
log.warn(`[channels] failed to load persisted auth checker for ${entry.pluginId}: ${detail}`);
persistedAuthStateCheckerCache.set(entry.pluginId, null);
return null;
}
}
hasBundledChannelPackageState,
listBundledChannelIdsForPackageState,
} from "./package-state-probes.js";
export function listBundledChannelIdsWithPersistedAuthState(): string[] {
return persistedAuthStateCatalog.map((entry) => entry.pluginId);
return listBundledChannelIdsForPackageState("persistedAuthState");
}
export function hasBundledChannelPersistedAuthState(params: {
@@ -169,10 +13,10 @@ export function hasBundledChannelPersistedAuthState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const entry = persistedAuthStateEntriesById.get(params.channelId);
if (!entry) {
return false;
}
const checker = resolvePersistedAuthStateChecker(entry);
return checker ? Boolean(checker({ cfg: params.cfg, env: params.env })) : false;
return hasBundledChannelPackageState({
metadataKey: "persistedAuthState",
channelId: params.channelId,
cfg: params.cfg,
env: params.env,
});
}

View File

@@ -2,19 +2,19 @@ import { describe, expect, it } from "vitest";
import { isChannelConfigured } from "./channel-configured.js";
describe("isChannelConfigured", () => {
it("detects Telegram env configuration through the channel plugin seam", () => {
it("detects Telegram env configuration through the package metadata seam", () => {
expect(isChannelConfigured({}, "telegram", { TELEGRAM_BOT_TOKEN: "token" })).toBe(true);
});
it("detects Discord env configuration through the channel plugin seam", () => {
it("detects Discord env configuration through the package metadata seam", () => {
expect(isChannelConfigured({}, "discord", { DISCORD_BOT_TOKEN: "token" })).toBe(true);
});
it("detects Slack env configuration through the channel plugin seam", () => {
it("detects Slack env configuration through the package metadata seam", () => {
expect(isChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(true);
});
it("requires both IRC host and nick env vars through the channel plugin seam", () => {
it("requires both IRC host and nick env vars through the package metadata seam", () => {
expect(isChannelConfigured({}, "irc", { IRC_HOST: "irc.example.com" })).toBe(false);
expect(
isChannelConfigured({}, "irc", {

View File

@@ -1,5 +1,6 @@
import { hasMeaningfulChannelConfig } from "../channels/config-presence.js";
import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js";
import { hasBundledChannelConfiguredState } from "../channels/plugins/configured-state.js";
import { hasBundledChannelPersistedAuthState } from "../channels/plugins/persisted-auth-state.js";
import { isRecord } from "../utils.js";
import type { OpenClawConfig } from "./config.js";
@@ -23,14 +24,16 @@ export function isChannelConfigured(
channelId: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const plugin = getBootstrapChannelPlugin(channelId);
const pluginConfigured = plugin?.config?.hasConfiguredState?.({ cfg, env });
if (pluginConfigured) {
if (hasBundledChannelConfiguredState({ channelId, cfg, env })) {
return true;
}
const pluginPersistedAuthState = hasBundledChannelPersistedAuthState({ channelId, cfg, env });
if (pluginPersistedAuthState) {
return true;
}
return isGenericChannelConfigured(cfg, channelId);
if (isGenericChannelConfigured(cfg, channelId)) {
return true;
}
const plugin = getBootstrapChannelPlugin(channelId);
return Boolean(plugin?.config?.hasConfiguredState?.({ cfg, env }));
}

View File

@@ -118,6 +118,12 @@ describe("plugin activation boundary", () => {
it("does not load bundled plugins for config and env detection helpers", async () => {
const { isChannelConfigured, resolveEnvApiKey } = await importConfigHelpers();
expect(isChannelConfigured({}, "telegram", { TELEGRAM_BOT_TOKEN: "token" })).toBe(true);
expect(isChannelConfigured({}, "discord", { DISCORD_BOT_TOKEN: "token" })).toBe(true);
expect(isChannelConfigured({}, "slack", { SLACK_BOT_TOKEN: "xoxb-test" })).toBe(true);
expect(
isChannelConfigured({}, "irc", { IRC_HOST: "irc.example.com", IRC_NICK: "openclaw" }),
).toBe(true);
expect(isChannelConfigured({}, "whatsapp", {})).toBe(false);
expect(
resolveEnvApiKey("anthropic-vertex", {

View File

@@ -107,6 +107,45 @@ describe("bundled plugin metadata", () => {
});
});
it("keeps bundled configured-state metadata on channel package manifests", () => {
const configuredChannels = listBundledPluginMetadata()
.filter((entry) => ["discord", "irc", "slack", "telegram"].includes(entry.dirName))
.map((entry) => ({
dir: entry.dirName,
configuredState: entry.packageManifest?.channel?.configuredState,
}));
expect(configuredChannels).toEqual([
{
dir: "discord",
configuredState: {
specifier: "./configured-state",
exportName: "hasDiscordConfiguredState",
},
},
{
dir: "irc",
configuredState: {
specifier: "./configured-state",
exportName: "hasIrcConfiguredState",
},
},
{
dir: "slack",
configuredState: {
specifier: "./configured-state",
exportName: "hasSlackConfiguredState",
},
},
{
dir: "telegram",
configuredState: {
specifier: "./configured-state",
exportName: "hasTelegramConfiguredState",
},
},
]);
});
it("excludes test-only public surface artifacts", () => {
listBundledPluginMetadata().forEach((entry) =>
expectTestOnlyArtifactsExcluded(entry.publicSurfaceArtifacts ?? []),

View File

@@ -438,6 +438,10 @@ export type PluginPackageChannel = {
quickstartAllowFrom?: boolean;
forceAccountBinding?: boolean;
preferSessionLookupForAnnounceTarget?: boolean;
configuredState?: {
specifier?: string;
exportName?: string;
};
persistedAuthState?: {
specifier?: string;
exportName?: string;