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

@@ -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.

View 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
);
}

View File

@@ -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",

View 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
);
}

View File

@@ -22,7 +22,11 @@
"aliases": [
"internet-relay-chat"
],
"systemImage": "network"
"systemImage": "network",
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasIrcConfiguredState"
}
}
}
}

View 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";

View 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,
);
}

View 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");
});
});

View File

@@ -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: {

View File

@@ -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

View File

@@ -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",
},
});

View 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";

View 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
);
}

View 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");
});
});

View File

@@ -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: {

View File

@@ -28,7 +28,11 @@
"selectionExtras": [
"https://openclaw.ai"
],
"markdownCapable": true
"markdownCapable": true,
"configuredState": {
"specifier": "./configured-state",
"exportName": "hasTelegramConfiguredState"
}
},
"bundle": {
"stageRuntimeDependencies": true

View File

@@ -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",
},
});

View File

@@ -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",

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;