mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 04:31:10 +00:00
refactor: add metadata-first channel configured-state probes
This commit is contained in:
@@ -335,17 +335,18 @@ Some pre-runtime plugin metadata intentionally lives in `package.json` under the
|
||||
|
||||
Important examples:
|
||||
|
||||
| Field | What it means |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `openclaw.extensions` | Declares native plugin entrypoints. |
|
||||
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding and deferred channel startup. |
|
||||
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22`. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
|
||||
| Field | What it means |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `openclaw.extensions` | Declares native plugin entrypoints. |
|
||||
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding and deferred channel startup. |
|
||||
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
|
||||
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
|
||||
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
|
||||
| `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
|
||||
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
|
||||
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22`. |
|
||||
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
|
||||
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. |
|
||||
|
||||
`openclaw.install.minHostVersion` is enforced during install and manifest
|
||||
registry loading. Invalid values are rejected; newer-but-valid values skip the
|
||||
@@ -380,6 +381,28 @@ probe before the full channel plugin loads. The target export should be a small
|
||||
function that reads persisted state only; do not route it through the full
|
||||
channel runtime barrel.
|
||||
|
||||
`openclaw.channel.configuredState` follows the same shape for cheap env-only
|
||||
configured checks:
|
||||
|
||||
```json
|
||||
{
|
||||
"openclaw": {
|
||||
"channel": {
|
||||
"id": "telegram",
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasTelegramConfiguredState"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use it when a channel can answer configured-state from env or other tiny
|
||||
non-runtime inputs. If the check needs full config resolution or the real
|
||||
channel runtime, keep that logic in the plugin `config.hasConfiguredState`
|
||||
hook instead.
|
||||
|
||||
## JSON Schema requirements
|
||||
|
||||
- **Every plugin must ship a JSON Schema**, even if it accepts no config.
|
||||
|
||||
6
extensions/discord/configured-state.ts
Normal file
6
extensions/discord/configured-state.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function hasDiscordConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
|
||||
return (
|
||||
typeof params.env?.DISCORD_BOT_TOKEN === "string" &&
|
||||
params.env.DISCORD_BOT_TOKEN.trim().length > 0
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,11 @@
|
||||
"docsLabel": "discord",
|
||||
"blurb": "very well supported right now.",
|
||||
"systemImage": "bubble.left.and.bubble.right",
|
||||
"markdownCapable": true
|
||||
"markdownCapable": true,
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasDiscordConfiguredState"
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/discord",
|
||||
|
||||
8
extensions/irc/configured-state.ts
Normal file
8
extensions/irc/configured-state.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function hasIrcConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
|
||||
return (
|
||||
typeof params.env?.IRC_HOST === "string" &&
|
||||
params.env.IRC_HOST.trim().length > 0 &&
|
||||
typeof params.env?.IRC_NICK === "string" &&
|
||||
params.env.IRC_NICK.trim().length > 0
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,11 @@
|
||||
"aliases": [
|
||||
"internet-relay-chat"
|
||||
],
|
||||
"systemImage": "network"
|
||||
"systemImage": "network",
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasIrcConfiguredState"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4
extensions/slack/channel-plugin-api.ts
Normal file
4
extensions/slack/channel-plugin-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag the broad Slack API barrel into lightweight plugin loads.
|
||||
export { slackPlugin } from "./src/channel.js";
|
||||
export { slackSetupPlugin } from "./src/channel.setup.js";
|
||||
7
extensions/slack/configured-state.ts
Normal file
7
extensions/slack/configured-state.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const SLACK_CONFIGURED_ENV_KEYS = ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"];
|
||||
|
||||
export function hasSlackConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
|
||||
return SLACK_CONFIGURED_ENV_KEYS.some(
|
||||
(key) => typeof params.env?.[key] === "string" && params.env[key]?.trim().length > 0,
|
||||
);
|
||||
}
|
||||
15
extensions/slack/index.test.ts
Normal file
15
extensions/slack/index.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("slack bundled entries", () => {
|
||||
it("loads the channel plugin without importing the broad api barrel", () => {
|
||||
const plugin = entry.loadChannelPlugin();
|
||||
expect(plugin.id).toBe("slack");
|
||||
});
|
||||
|
||||
it("loads the setup plugin without importing the broad api barrel", () => {
|
||||
const plugin = setupEntry.loadSetupPlugin();
|
||||
expect(plugin.id).toBe("slack");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ export default defineBundledChannelEntry({
|
||||
description: "Slack channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "slackPlugin",
|
||||
},
|
||||
runtime: {
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
"docsLabel": "slack",
|
||||
"blurb": "supported (Socket Mode).",
|
||||
"systemImage": "number",
|
||||
"markdownCapable": true
|
||||
"markdownCapable": true,
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasSlackConfiguredState"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "slackSetupPlugin",
|
||||
},
|
||||
});
|
||||
|
||||
4
extensions/telegram/channel-plugin-api.ts
Normal file
4
extensions/telegram/channel-plugin-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag the broad Telegram API barrel into lightweight plugin loads.
|
||||
export { telegramPlugin } from "./src/channel.js";
|
||||
export { telegramSetupPlugin } from "./src/channel.setup.js";
|
||||
6
extensions/telegram/configured-state.ts
Normal file
6
extensions/telegram/configured-state.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function hasTelegramConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
|
||||
return (
|
||||
typeof params.env?.TELEGRAM_BOT_TOKEN === "string" &&
|
||||
params.env.TELEGRAM_BOT_TOKEN.trim().length > 0
|
||||
);
|
||||
}
|
||||
15
extensions/telegram/index.test.ts
Normal file
15
extensions/telegram/index.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("telegram bundled entries", () => {
|
||||
it("loads the channel plugin without importing the broad api barrel", () => {
|
||||
const plugin = entry.loadChannelPlugin();
|
||||
expect(plugin.id).toBe("telegram");
|
||||
});
|
||||
|
||||
it("loads the setup plugin without importing the broad api barrel", () => {
|
||||
const plugin = setupEntry.loadSetupPlugin();
|
||||
expect(plugin.id).toBe("telegram");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ export default defineBundledChannelEntry({
|
||||
description: "Telegram channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "telegramPlugin",
|
||||
},
|
||||
runtime: {
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
"selectionExtras": [
|
||||
"https://openclaw.ai"
|
||||
],
|
||||
"markdownCapable": true
|
||||
"markdownCapable": true,
|
||||
"configuredState": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasTelegramConfiguredState"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entr
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "telegramSetupPlugin",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"dist/extensions/nostr/runtime-api.js",
|
||||
"dist/extensions/ollama/runtime-api.js",
|
||||
"dist/extensions/open-prose/runtime-api.js",
|
||||
"dist/extensions/qa-channel/runtime-api.js",
|
||||
"dist/extensions/qa-lab/runtime-api.js",
|
||||
"dist/extensions/qqbot/runtime-api.js",
|
||||
"dist/extensions/signal/runtime-api.js",
|
||||
"dist/extensions/slack/runtime-api.js",
|
||||
|
||||
44
src/channels/plugins/configured-state.test.ts
Normal file
44
src/channels/plugins/configured-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
src/channels/plugins/configured-state.ts
Normal file
22
src/channels/plugins/configured-state.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
211
src/channels/plugins/package-state-probes.ts
Normal file
211
src/channels/plugins/package-state-probes.ts
Normal 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;
|
||||
}
|
||||
21
src/channels/plugins/persisted-auth-state.test.ts
Normal file
21
src/channels/plugins/persisted-auth-state.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 ?? []),
|
||||
|
||||
@@ -438,6 +438,10 @@ export type PluginPackageChannel = {
|
||||
quickstartAllowFrom?: boolean;
|
||||
forceAccountBinding?: boolean;
|
||||
preferSessionLookupForAnnounceTarget?: boolean;
|
||||
configuredState?: {
|
||||
specifier?: string;
|
||||
exportName?: string;
|
||||
};
|
||||
persistedAuthState?: {
|
||||
specifier?: string;
|
||||
exportName?: string;
|
||||
|
||||
Reference in New Issue
Block a user