fix: stabilize serial test suite

This commit is contained in:
Peter Steinberger
2026-03-30 04:45:09 +09:00
parent b787669340
commit 855878b4f0
23 changed files with 942 additions and 93 deletions

View File

@@ -1,4 +1,3 @@
import { vi } from "vitest";
import type { BuildTelegramMessageContextParams, TelegramMediaRef } from "./bot-message-context.js";
export const baseTelegramMessageContextConfig = {
@@ -23,6 +22,7 @@ export async function buildTelegramMessageContextForTest(
): Promise<
Awaited<ReturnType<typeof import("./bot-message-context.js").buildTelegramMessageContext>>
> {
const { vi } = await import("vitest");
const { buildTelegramMessageContext } = await import("./bot-message-context.js");
return await buildTelegramMessageContext({
primaryCtx: {

View File

@@ -1,4 +1,4 @@
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "openclaw/plugin-sdk/zod";
const ShipSchema = z.string().min(1);

View File

@@ -0,0 +1,286 @@
import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef } from "../config/types.secrets.js";
import type { TelegramAccountConfig } from "../config/types.telegram.js";
import { tryReadSecretFileSync } from "../infra/secret-file.js";
import {
resolveAccountWithDefaultFallback,
listCombinedAccountIds,
resolveListedDefaultAccountId,
resolveAccountEntry,
} from "../plugin-sdk/account-core.js";
import { resolveDefaultSecretProviderAlias } from "../plugin-sdk/provider-auth.js";
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";
export type TelegramCredentialStatus = "available" | "configured_unavailable" | "missing";
export type InspectedTelegramAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "tokenFile" | "config" | "none";
tokenStatus: TelegramCredentialStatus;
configured: boolean;
config: TelegramAccountConfig;
};
export function normalizeTelegramAllowFromEntry(raw: unknown): string {
const base = typeof raw === "string" ? raw : typeof raw === "number" ? String(raw) : "";
return base
.trim()
.replace(/^(telegram|tg):/i, "")
.trim();
}
export function isNumericTelegramUserId(raw: string): boolean {
return /^-?\d+$/.test(raw);
}
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const ids = new Set<string>();
for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) {
if (key) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
export function listTelegramAccountIds(cfg: OpenClawConfig): string[] {
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds(cfg),
additionalAccountIds: listBoundAccountIds(cfg, "telegram"),
fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID,
});
}
function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string {
const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram");
if (boundDefault) {
return boundDefault;
}
return resolveListedDefaultAccountId({
accountIds: listTelegramAccountIds(cfg),
configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount),
});
}
function resolveTelegramAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): TelegramAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalizeAccountId(accountId));
}
function mergeTelegramAccountConfig(cfg: OpenClawConfig, accountId: string): TelegramAccountConfig {
const {
accounts: _ignored,
defaultAccount: _ignoredDefaultAccount,
groups: channelGroups,
...base
} = (cfg.channels?.telegram ?? {}) as TelegramAccountConfig & {
accounts?: unknown;
defaultAccount?: unknown;
};
const account = resolveTelegramAccountConfig(cfg, accountId) ?? {};
const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {});
const groups = account.groups ?? (configuredAccountIds.length > 1 ? undefined : channelGroups);
return { ...base, ...account, groups };
}
function inspectTokenFile(pathValue: unknown): {
token: string;
tokenSource: "tokenFile" | "none";
tokenStatus: TelegramCredentialStatus;
} | null {
const tokenFile = typeof pathValue === "string" ? pathValue.trim() : "";
if (!tokenFile) {
return null;
}
const token = tryReadSecretFileSync(tokenFile, "Telegram bot token", {
rejectSymlink: true,
});
return {
token: token ?? "",
tokenSource: "tokenFile",
tokenStatus: token ? "available" : "configured_unavailable",
};
}
function canResolveEnvSecretRefInReadOnlyPath(params: {
cfg: OpenClawConfig;
provider: string;
id: string;
}): boolean {
const providerConfig = params.cfg.secrets?.providers?.[params.provider];
if (!providerConfig) {
return params.provider === resolveDefaultSecretProviderAlias(params.cfg, "env");
}
if (providerConfig.source !== "env") {
return false;
}
const allowlist = providerConfig.allowlist;
return !allowlist || allowlist.includes(params.id);
}
function hasConfiguredSecretInput(value: unknown): boolean {
return Boolean(coerceSecretRef(value) || (typeof value === "string" && value.trim()));
}
function normalizeSecretInputString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function inspectTokenValue(params: { cfg: OpenClawConfig; value: unknown }): {
token: string;
tokenSource: "config" | "env" | "none";
tokenStatus: TelegramCredentialStatus;
} | null {
const ref = coerceSecretRef(params.value, params.cfg.secrets?.defaults);
if (ref?.source === "env") {
if (
!canResolveEnvSecretRefInReadOnlyPath({
cfg: params.cfg,
provider: ref.provider,
id: ref.id,
})
) {
return { token: "", tokenSource: "env", tokenStatus: "configured_unavailable" };
}
const envValue = process.env[ref.id];
if (envValue && envValue.trim()) {
return { token: envValue.trim(), tokenSource: "env", tokenStatus: "available" };
}
return { token: "", tokenSource: "env", tokenStatus: "configured_unavailable" };
}
const token = normalizeSecretInputString(params.value);
if (token) {
return { token, tokenSource: "config", tokenStatus: "available" };
}
if (hasConfiguredSecretInput(params.value)) {
return { token: "", tokenSource: "config", tokenStatus: "configured_unavailable" };
}
return null;
}
function inspectTelegramAccountPrimary(params: {
cfg: OpenClawConfig;
accountId: string;
envToken?: string | null;
}): InspectedTelegramAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = mergeTelegramAccountConfig(params.cfg, accountId);
const enabled = params.cfg.channels?.telegram?.enabled !== false && merged.enabled !== false;
const accountConfig = resolveTelegramAccountConfig(params.cfg, accountId);
const accountTokenFile = inspectTokenFile(accountConfig?.tokenFile);
if (accountTokenFile) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: accountTokenFile.token,
tokenSource: accountTokenFile.tokenSource,
tokenStatus: accountTokenFile.tokenStatus,
configured: accountTokenFile.tokenStatus !== "missing",
config: merged,
};
}
const accountToken = inspectTokenValue({ cfg: params.cfg, value: accountConfig?.botToken });
if (accountToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: accountToken.tokenStatus !== "missing",
config: merged,
};
}
const channelTokenFile = inspectTokenFile(params.cfg.channels?.telegram?.tokenFile);
if (channelTokenFile) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: channelTokenFile.token,
tokenSource: channelTokenFile.tokenSource,
tokenStatus: channelTokenFile.tokenStatus,
configured: channelTokenFile.tokenStatus !== "missing",
config: merged,
};
}
const channelToken = inspectTokenValue({
cfg: params.cfg,
value: params.cfg.channels?.telegram?.botToken,
});
if (channelToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: channelToken.tokenStatus !== "missing",
config: merged,
};
}
const envToken =
accountId === DEFAULT_ACCOUNT_ID
? (params.envToken ?? process.env.TELEGRAM_BOT_TOKEN)?.trim()
: "";
if (envToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: envToken,
tokenSource: "env",
tokenStatus: "available",
configured: true,
config: merged,
};
}
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}
export function inspectTelegramAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
envToken?: string | null;
}): InspectedTelegramAccount {
return resolveAccountWithDefaultFallback({
accountId: params.accountId,
normalizeAccountId,
resolvePrimary: (accountId) =>
inspectTelegramAccountPrimary({
cfg: params.cfg,
accountId,
envToken: params.envToken,
}),
hasCredential: (account) => account.tokenSource !== "none",
resolveDefaultAccountId: () => resolveDefaultTelegramAccountId(params.cfg),
});
}

View File

@@ -3,7 +3,7 @@ import type { ChannelId } from "./plugins/types.js";
type DiscordInspectModule = typeof import("./read-only-account-inspect.discord.runtime.js");
type SlackInspectModule = typeof import("./read-only-account-inspect.slack.runtime.js");
type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.runtime.js");
type TelegramInspectModule = typeof import("./read-only-account-inspect.telegram.js");
let discordInspectModulePromise: Promise<DiscordInspectModule> | undefined;
let slackInspectModulePromise: Promise<SlackInspectModule> | undefined;
@@ -20,7 +20,7 @@ function loadSlackInspectModule() {
}
function loadTelegramInspectModule() {
telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.runtime.js");
telegramInspectModulePromise ??= import("./read-only-account-inspect.telegram.js");
return telegramInspectModulePromise;
}

View File

@@ -83,6 +83,7 @@ vi.mock("../../config/config.js", () => ({
loadConfig: () => (isDaemon ? daemonLoadedConfig : cliLoadedConfig),
};
},
loadConfig: () => cliLoadedConfig,
resolveConfigPath: (env: NodeJS.ProcessEnv, stateDir: string) => resolveConfigPath(env, stateDir),
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
resolveStateDir: (env: NodeJS.ProcessEnv) => resolveStateDir(env),

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { resolveMatrixAccountStorageRoot } from "../plugin-sdk/matrix.js";
import { resolveMatrixAccountStorageRoot } from "../infra/matrix-config-helpers.js";
import * as noteModule from "../terminal/note.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { runDoctorConfigWithInput } from "./doctor-config-flow.test-utils.js";

View File

@@ -219,7 +219,14 @@ describe("doctor matrix provider helpers", () => {
try {
const result = await runMatrixDoctorSequence({
cfg: {},
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
},
env: process.env,
shouldRepair: false,
});
@@ -234,4 +241,30 @@ describe("doctor matrix provider helpers", () => {
cryptoSpy.mockRestore();
}
});
it("skips Matrix migration probes for unrelated configs", async () => {
const matrixStateModule = await import("../../../infra/matrix-legacy-state.js");
const matrixCryptoModule = await import("../../../infra/matrix-legacy-crypto.js");
const stateSpy = vi.spyOn(matrixStateModule, "detectLegacyMatrixState");
const cryptoSpy = vi.spyOn(matrixCryptoModule, "detectLegacyMatrixCrypto");
try {
const result = await runMatrixDoctorSequence({
cfg: {
gateway: { auth: { mode: "token", token: "123" } },
agents: { list: [{ id: "pi" }] },
},
env: {},
shouldRepair: false,
});
expect(result).toEqual({ changeNotes: [], warningNotes: [] });
expect(stateSpy).not.toHaveBeenCalled();
expect(cryptoSpy).not.toHaveBeenCalled();
} finally {
stateSpy.mockRestore();
cryptoSpy.mockRestore();
}
});
});

View File

@@ -19,6 +19,7 @@ import {
} from "../../../infra/plugin-install-path-warnings.js";
import { resolveBundledPluginInstallCommandHint } from "../../../plugins/bundled-sources.js";
import { removePluginFromConfig } from "../../../plugins/uninstall.js";
import { isRecord } from "../../../utils.js";
import type { DoctorConfigMutationResult } from "../shared/config-mutation-state.js";
export function formatMatrixLegacyStatePreview(
@@ -74,6 +75,34 @@ export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Pro
}).map((entry) => `- ${entry}`);
}
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
return isRecord(channels?.matrix);
}
function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.installs?.matrix ||
cfg.plugins?.entries?.matrix ||
cfg.plugins?.allow?.includes("matrix") ||
cfg.plugins?.deny?.includes("matrix"),
);
}
function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean {
return Object.entries(env).some(
([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(),
);
}
function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return (
hasConfiguredMatrixChannel(cfg) ||
hasConfiguredMatrixPluginSurface(cfg) ||
hasConfiguredMatrixEnv(env)
);
}
/**
* Produces a config mutation that removes stale Matrix plugin install/load-path
* references left behind by the old bundled-plugin layout. When the install
@@ -199,6 +228,16 @@ export async function runMatrixDoctorSequence(params: {
env: NodeJS.ProcessEnv;
shouldRepair: boolean;
}): Promise<{ changeNotes: string[]; warningNotes: string[] }> {
const warningNotes: string[] = [];
const changeNotes: string[] = [];
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(params.cfg);
if (matrixInstallWarnings.length > 0) {
warningNotes.push(matrixInstallWarnings.join("\n"));
}
if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) {
return { changeNotes, warningNotes };
}
const matrixLegacyState = detectLegacyMatrixState({
cfg: params.cfg,
env: params.env,
@@ -207,8 +246,6 @@ export async function runMatrixDoctorSequence(params: {
cfg: params.cfg,
env: params.env,
});
const warningNotes: string[] = [];
const changeNotes: string[] = [];
if (params.shouldRepair) {
const matrixRepair = await applyMatrixDoctorRepair({
@@ -232,10 +269,5 @@ export async function runMatrixDoctorSequence(params: {
warningNotes.push(...formatMatrixLegacyCryptoPreview(matrixLegacyCrypto));
}
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(params.cfg);
if (matrixInstallWarnings.length > 0) {
warningNotes.push(matrixInstallWarnings.join("\n"));
}
return { changeNotes, warningNotes };
}

View File

@@ -1,15 +1,14 @@
import {
inspectTelegramAccount,
isNumericTelegramUserId,
listTelegramAccountIds,
normalizeTelegramAllowFromEntry,
} from "../../../channels/read-only-account-inspect.telegram.js";
import { resolveCommandSecretRefsViaGateway } from "../../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../../cli/command-secret-targets.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { TelegramNetworkConfig } from "../../../config/types.telegram.js";
import { resolveTelegramAccount } from "../../../plugin-sdk/account-resolution.js";
import {
isNumericTelegramUserId,
normalizeTelegramAllowFromEntry,
inspectTelegramAccount,
listTelegramAccountIds,
lookupTelegramChatId,
} from "../../../plugin-sdk/telegram.js";
import { lookupTelegramChatId } from "../../../plugin-sdk/telegram.js";
import { describeUnknownError } from "../../../secrets/shared.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { hasAllowFromEntries } from "../shared/allowlist.js";
@@ -164,20 +163,25 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig)
const lookupAccounts: ResolvedTelegramLookupAccount[] = [];
const seenLookupAccounts = new Set<string>();
for (const accountId of listTelegramAccountIds(resolvedConfig)) {
let account: NonNullable<ReturnType<typeof resolveTelegramAccount>>;
let inspected: ReturnType<typeof inspectTelegramAccount>;
try {
account = resolveTelegramAccount({ cfg: resolvedConfig, accountId });
inspected = inspectTelegramAccount({ cfg: resolvedConfig, accountId });
} catch (error) {
tokenResolutionWarnings.push(
`- Telegram account ${accountId}: failed to inspect bot token (${describeUnknownError(error)}).`,
);
continue;
}
const token = account.tokenSource === "none" ? "" : account.token.trim();
if (inspected.tokenStatus === "configured_unavailable") {
tokenResolutionWarnings.push(
`- Telegram account ${accountId}: failed to inspect bot token (configured but unavailable in this command path).`,
);
}
const token = inspected.tokenSource === "none" ? "" : inspected.token.trim();
if (!token) {
continue;
}
const network = account.config.network;
const network = inspected.config.network;
const cacheKey = `${token}::${JSON.stringify(network ?? {})}`;
if (seenLookupAccounts.has(cacheKey)) {
continue;

View File

@@ -186,6 +186,36 @@ describe("applyPluginAutoEnable", () => {
expect(result.changes).toEqual([]);
});
it("skips auto-enable work for configs without channel or plugin-owned surfaces", () => {
const result = applyPluginAutoEnable({
config: {
gateway: {
auth: {
mode: "token",
token: "ok",
},
},
agents: {
list: [{ id: "pi" }],
},
},
env: {},
});
expect(result.config).toEqual({
gateway: {
auth: {
mode: "token",
token: "ok",
},
},
agents: {
list: [{ id: "pi" }],
},
});
expect(result.changes).toEqual([]);
});
it("ignores channels.modelByChannel for plugin auto-enable", () => {
const result = applyPluginAutoEnable({
config: {

View File

@@ -2,10 +2,10 @@ import fs from "node:fs";
import path from "node:path";
import { normalizeProviderId } from "../agents/model-selection.js";
import {
getChatChannelMeta,
listChatChannels,
normalizeChatChannelId,
} from "../channels/registry.js";
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import {
BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS,
BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS,
@@ -283,24 +283,20 @@ function resolvePluginIdForChannel(
return channelToPluginId.get(channelId) ?? channelId;
}
function listKnownChannelPluginIds(): string[] {
return listChatChannels().map((meta) => meta.id);
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
return listPotentialConfiguredChannelIds(cfg, env).map(
(channelId) => normalizeChatChannelId(channelId) ?? channelId,
);
}
function collectCandidateChannelIds(cfg: OpenClawConfig): string[] {
const channelIds = new Set<string>(listKnownChannelPluginIds());
const configuredChannels = cfg.channels as Record<string, unknown> | undefined;
if (!configuredChannels || typeof configuredChannels !== "object") {
return Array.from(channelIds);
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
const entries = cfg.plugins?.entries;
if (!entries || typeof entries !== "object") {
return false;
}
for (const key of Object.keys(configuredChannels)) {
if (key === "defaults" || key === "modelByChannel") {
continue;
}
const normalizedBuiltIn = normalizeChatChannelId(key);
channelIds.add(normalizedBuiltIn ?? key);
}
return Array.from(channelIds);
return Object.values(entries).some(
(entry) => isRecord(entry) && isRecord(entry.config) && isRecord(entry.config.webSearch),
);
}
function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
@@ -319,6 +315,37 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig): boolean {
return false;
}
function configMayNeedPluginAutoEnable(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
if (hasPotentialConfiguredChannels(cfg, env)) {
return true;
}
if (resolveBrowserAutoEnableReason(cfg)) {
return true;
}
if (cfg.acp?.enabled === true || cfg.acp?.dispatch?.enabled === true) {
return true;
}
if (typeof cfg.acp?.backend === "string" && cfg.acp.backend.trim().length > 0) {
return true;
}
if (cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) {
return true;
}
if (cfg.models?.providers && Object.keys(cfg.models.providers).length > 0) {
return true;
}
if (collectModelRefs(cfg).length > 0) {
return true;
}
if (isRecord(cfg.tools?.web?.x_search as Record<string, unknown> | undefined)) {
return true;
}
if (isRecord(cfg.plugins?.entries?.xai?.config) || hasConfiguredWebSearchPluginEntry(cfg)) {
return true;
}
return false;
}
function listContainsBrowser(value: unknown): boolean {
return (
Array.isArray(value) &&
@@ -380,7 +407,7 @@ function resolveConfiguredPlugins(
const changes: PluginEnableChange[] = [];
// Build reverse map: channel ID → plugin ID from installed plugin manifests.
const channelToPluginId = buildChannelToPluginIdMap(registry);
for (const channelId of collectCandidateChannelIds(cfg)) {
for (const channelId of collectCandidateChannelIds(cfg, env)) {
const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId);
if (isChannelConfigured(cfg, channelId, env)) {
changes.push({ pluginId, reason: `${channelId} configured` });
@@ -555,6 +582,9 @@ export function applyPluginAutoEnable(params: {
manifestRegistry?: PluginManifestRegistry;
}): PluginAutoEnableResult {
const env = params.env ?? process.env;
if (!configMayNeedPluginAutoEnable(params.config, env)) {
return { config: params.config, changes: [] };
}
const registry =
params.manifestRegistry ??
(configMayNeedPluginManifestRegistry(params.config)

View File

@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
const { resolveRuntimePluginRegistryMock } = vi.hoisted(() => ({
resolveRuntimePluginRegistryMock: vi.fn<
@@ -15,14 +16,27 @@ vi.mock("../plugins/loader.js", () => ({
let generateImage: typeof import("./runtime.js").generateImage;
let listRuntimeImageGenerationProviders: typeof import("./runtime.js").listRuntimeImageGenerationProviders;
function setCompatibleActiveImageGenerationRegistry(
pluginRegistry: ReturnType<typeof createEmptyPluginRegistry>,
_cfg: OpenClawConfig,
) {
setActivePluginRegistry(pluginRegistry);
}
describe("image-generation runtime helpers", () => {
afterEach(() => {
resolveRuntimePluginRegistryMock.mockReset();
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
resetPluginRuntimeStateForTest();
vi.doUnmock("../plugins/loader.js");
});
beforeEach(async () => {
vi.resetModules();
resetPluginRuntimeStateForTest();
vi.doMock("../plugins/loader.js", () => ({
resolveRuntimePluginRegistry: resolveRuntimePluginRegistryMock,
}));
({ generateImage, listRuntimeImageGenerationProviders } = await import("./runtime.js"));
});
@@ -66,6 +80,7 @@ describe("image-generation runtime helpers", () => {
},
},
} as OpenClawConfig;
setCompatibleActiveImageGenerationRegistry(pluginRegistry, cfg);
const result = await generateImage({
cfg,
@@ -115,6 +130,7 @@ describe("image-generation runtime helpers", () => {
},
});
resolveRuntimePluginRegistryMock.mockReturnValue(pluginRegistry);
setCompatibleActiveImageGenerationRegistry(pluginRegistry, {} as OpenClawConfig);
expect(listRuntimeImageGenerationProviders()).toMatchObject([
{
@@ -174,6 +190,7 @@ describe("image-generation runtime helpers", () => {
},
);
resolveRuntimePluginRegistryMock.mockReturnValue(pluginRegistry);
setCompatibleActiveImageGenerationRegistry(pluginRegistry, {} as OpenClawConfig);
const promise = generateImage({ cfg: {} as OpenClawConfig, prompt: "draw a cat" });
@@ -204,6 +221,7 @@ describe("image-generation runtime helpers", () => {
},
});
resolveRuntimePluginRegistryMock.mockReturnValue(pluginRegistry);
setCompatibleActiveImageGenerationRegistry(pluginRegistry, {} as OpenClawConfig);
await expect(
generateImage({ cfg: {} as OpenClawConfig, prompt: "draw a cat" }),

View File

@@ -0,0 +1,96 @@
import type {
ChannelId,
ChannelOutboundAdapter,
ChannelPlugin,
} from "../channels/plugins/types.js";
import { createOutboundTestPlugin } from "../test-utils/channel-plugins.js";
import { resolveOutboundSendDep, type OutboundSendDeps } from "./outbound/send-deps.js";
type HeartbeatSendChannelId = "slack" | "telegram" | "whatsapp";
type HeartbeatSendFn = (
to: string,
text: string,
opts?: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter {
return {
deliveryMode: "direct",
sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => {
const send = resolveOutboundSendDep<HeartbeatSendFn>(deps as OutboundSendDeps, channelId);
if (!send) {
throw new Error(`Missing ${channelId} outbound send dependency`);
}
const baseOptions = {
verbose: false,
cfg,
accountId,
};
const sendOptions =
channelId === "telegram"
? {
...baseOptions,
...(typeof threadId === "number" ? { messageThreadId: threadId } : {}),
...(typeof replyToId === "string" ? { replyToMessageId: Number(replyToId) } : {}),
}
: {
...baseOptions,
...opts,
...(replyToId ? { replyToId } : {}),
...(threadId !== undefined ? { threadId } : {}),
};
return (await send(to, text, sendOptions)) as never;
},
};
}
function createHeartbeatChannelPlugin(params: {
id: HeartbeatSendChannelId;
label: string;
docsPath: string;
heartbeat?: ChannelPlugin["heartbeat"];
}): ChannelPlugin {
return {
...createOutboundTestPlugin({
id: params.id as ChannelId,
label: params.label,
docsPath: params.docsPath,
outbound: createHeartbeatOutboundAdapter(params.id),
}),
...(params.heartbeat ? { heartbeat: params.heartbeat } : {}),
};
}
export const heartbeatRunnerSlackPlugin = createHeartbeatChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
});
export const heartbeatRunnerTelegramPlugin = createHeartbeatChannelPlugin({
id: "telegram",
label: "Telegram",
docsPath: "/channels/telegram",
});
export const heartbeatRunnerWhatsAppPlugin = createHeartbeatChannelPlugin({
id: "whatsapp",
label: "WhatsApp",
docsPath: "/channels/whatsapp",
heartbeat: {
checkReady: async ({ cfg, deps }) => {
if (cfg.web?.enabled === false) {
return { ok: false, reason: "whatsapp-disabled" };
}
const authExists = await (deps?.webAuthExists ?? (async () => true))();
if (!authExists) {
return { ok: false, reason: "whatsapp-not-linked" };
}
const listenerActive = deps?.hasActiveWebListener ? deps.hasActiveWebListener() : true;
if (!listenerActive) {
return { ok: false, reason: "whatsapp-not-running" };
}
return { ok: true, reason: "ok" };
},
},
});

View File

@@ -1,47 +1,28 @@
import { beforeEach } from "vitest";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime, type PluginRuntime } from "../plugins/runtime/index.js";
import { loadBundledPluginTestApiSync } from "../test-utils/bundled-plugin-public-surface.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
const { slackPlugin, setSlackRuntime } = loadBundledPluginTestApiSync<{
slackPlugin: ChannelPlugin;
setSlackRuntime: (runtime: PluginRuntime) => void;
}>("slack");
const { telegramPlugin, setTelegramRuntime } = loadBundledPluginTestApiSync<{
telegramPlugin: ChannelPlugin;
setTelegramRuntime: (runtime: PluginRuntime) => void;
}>("telegram");
const { whatsappPlugin, setWhatsAppRuntime } = loadBundledPluginTestApiSync<{
whatsappPlugin: ChannelPlugin;
setWhatsAppRuntime: (runtime: PluginRuntime) => void;
}>("whatsapp");
const slackChannelPlugin = slackPlugin as unknown as ChannelPlugin;
const telegramChannelPlugin = telegramPlugin as unknown as ChannelPlugin;
const whatsappChannelPlugin = whatsappPlugin as unknown as ChannelPlugin;
import {
heartbeatRunnerSlackPlugin,
heartbeatRunnerTelegramPlugin,
heartbeatRunnerWhatsAppPlugin,
} from "./heartbeat-runner.test-channel-plugins.js";
export function installHeartbeatRunnerTestRuntime(params?: { includeSlack?: boolean }): void {
beforeEach(() => {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setWhatsAppRuntime(runtime);
if (params?.includeSlack) {
setSlackRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "slack", plugin: slackChannelPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
{ pluginId: "slack", plugin: heartbeatRunnerSlackPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: heartbeatRunnerWhatsAppPlugin, source: "test" },
{ pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" },
]),
);
return;
}
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappChannelPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramChannelPlugin, source: "test" },
{ pluginId: "whatsapp", plugin: heartbeatRunnerWhatsAppPlugin, source: "test" },
{ pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" },
]),
);
});

View File

@@ -3,18 +3,11 @@ import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import * as replyModule from "../auto-reply/reply.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime, type PluginRuntime } from "../plugins/runtime/index.js";
import { loadBundledPluginTestApiSync } from "../test-utils/bundled-plugin-public-surface.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
const { telegramPlugin, setTelegramRuntime } = loadBundledPluginTestApiSync<{
telegramPlugin: ChannelPlugin;
setTelegramRuntime: (runtime: PluginRuntime) => void;
}>("telegram");
import { heartbeatRunnerTelegramPlugin } from "./heartbeat-runner.test-channel-plugins.js";
export type HeartbeatSessionSeed = {
sessionId?: string;
@@ -103,9 +96,9 @@ export async function withTempTelegramHeartbeatSandbox<T>(
}
export function setupTelegramHeartbeatPluginRuntimeForTests() {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]),
createTestRegistry([
{ pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" },
]),
);
}

View File

@@ -0,0 +1,264 @@
import crypto from "node:crypto";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import {
listCombinedAccountIds,
listConfiguredAccountIds,
resolveListedDefaultAccountId,
resolveNormalizedAccountEntry,
} from "../plugin-sdk/account-core.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "../routing/session-key.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
const MATRIX_SCOPED_ENV_SUFFIXES = [
"HOMESERVER",
"USER_ID",
"ACCESS_TOKEN",
"PASSWORD",
"DEVICE_ID",
"DEVICE_NAME",
] as const;
const MATRIX_GLOBAL_ENV_KEYS = MATRIX_SCOPED_ENV_SUFFIXES.map((suffix) => `MATRIX_${suffix}`);
const MATRIX_SCOPED_ENV_RE = new RegExp(`^MATRIX_(.+)_(${MATRIX_SCOPED_ENV_SUFFIXES.join("|")})$`);
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
export function findMatrixAccountEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return null;
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
if (!accounts) {
return null;
}
const entry = resolveNormalizedAccountEntry(accounts, accountId, normalizeAccountId);
return isRecord(entry) ? entry : null;
}
export function resolveMatrixEnvAccountToken(accountId: string): string {
return Array.from(normalizeAccountId(accountId))
.map((char) =>
/[a-z0-9]/.test(char)
? char.toUpperCase()
: `_X${char.codePointAt(0)?.toString(16).toUpperCase() ?? "00"}_`,
)
.join("");
}
export function getMatrixScopedEnvVarNames(accountId: string): {
homeserver: string;
userId: string;
accessToken: string;
password: string;
deviceId: string;
deviceName: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: `MATRIX_${token}_HOMESERVER`,
userId: `MATRIX_${token}_USER_ID`,
accessToken: `MATRIX_${token}_ACCESS_TOKEN`,
password: `MATRIX_${token}_PASSWORD`,
deviceId: `MATRIX_${token}_DEVICE_ID`,
deviceName: `MATRIX_${token}_DEVICE_NAME`,
};
}
function decodeMatrixEnvAccountToken(token: string): string | undefined {
let decoded = "";
for (let index = 0; index < token.length; ) {
const hexEscape = /^_X([0-9A-F]+)_/.exec(token.slice(index));
if (hexEscape) {
const hex = hexEscape[1];
const codePoint = hex ? Number.parseInt(hex, 16) : Number.NaN;
if (!Number.isFinite(codePoint)) {
return undefined;
}
decoded += String.fromCodePoint(codePoint);
index += hexEscape[0].length;
continue;
}
const char = token[index];
if (!char || !/[A-Z0-9]/.test(char)) {
return undefined;
}
decoded += char.toLowerCase();
index += 1;
}
const normalized = normalizeOptionalAccountId(decoded);
if (!normalized) {
return undefined;
}
return resolveMatrixEnvAccountToken(normalized) === token ? normalized : undefined;
}
export function listMatrixEnvAccountIds(env: NodeJS.ProcessEnv = process.env): string[] {
const ids = new Set<string>();
for (const key of MATRIX_GLOBAL_ENV_KEYS) {
if (typeof env[key] === "string" && env[key]?.trim()) {
ids.add(DEFAULT_ACCOUNT_ID);
break;
}
}
for (const key of Object.keys(env)) {
const match = MATRIX_SCOPED_ENV_RE.exec(key);
if (!match) {
continue;
}
const accountId = decodeMatrixEnvAccountToken(match[1]);
if (accountId) {
ids.add(accountId);
}
}
return Array.from(ids).toSorted((a, b) => a.localeCompare(b));
}
export function resolveConfiguredMatrixAccountIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const channel = resolveMatrixChannelConfig(cfg);
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds({
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
normalizeAccountId,
}),
additionalAccountIds: listMatrixEnvAccountIds(env),
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
});
}
export function resolveMatrixDefaultOrOnlyAccountId(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): string {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return DEFAULT_ACCOUNT_ID;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return resolveListedDefaultAccountId({
accountIds: resolveConfiguredMatrixAccountIds(cfg, env),
configuredDefaultAccountId: configuredDefault,
ambiguousFallbackAccountId: DEFAULT_ACCOUNT_ID,
});
}
export function requiresExplicitMatrixDefaultAccount(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return false;
}
const configuredAccountIds = resolveConfiguredMatrixAccountIds(cfg, env);
if (configuredAccountIds.length <= 1) {
return false;
}
const configuredDefault = normalizeOptionalAccountId(
typeof channel.defaultAccount === "string" ? channel.defaultAccount : undefined,
);
return !(configuredDefault && configuredAccountIds.includes(configuredDefault));
}
function sanitizeMatrixPathSegment(value: string): string {
const cleaned = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "");
return cleaned || "unknown";
}
function resolveMatrixHomeserverKey(homeserver: string): string {
try {
const url = new URL(homeserver);
if (url.host) {
return sanitizeMatrixPathSegment(url.host);
}
} catch {
// fall through
}
return sanitizeMatrixPathSegment(homeserver);
}
function hashMatrixAccessToken(accessToken: string): string {
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
}
function resolveMatrixCredentialsFilename(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
return normalized === DEFAULT_ACCOUNT_ID ? "credentials.json" : `credentials-${normalized}.json`;
}
function resolveMatrixCredentialsDir(stateDir: string): string {
return path.join(stateDir, "credentials", "matrix");
}
export function resolveMatrixCredentialsPath(params: {
stateDir: string;
accountId?: string | null;
}): string {
return path.join(
resolveMatrixCredentialsDir(params.stateDir),
resolveMatrixCredentialsFilename(params.accountId),
);
}
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const rootDir = path.join(stateDir, "matrix");
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
};
}
export function resolveMatrixAccountStorageRoot(params: {
stateDir: string;
homeserver: string;
userId: string;
accessToken: string;
accountId?: string | null;
}): {
rootDir: string;
accountKey: string;
tokenHash: string;
} {
const accountKey = sanitizeMatrixPathSegment(params.accountId ?? DEFAULT_ACCOUNT_ID);
const userKey = sanitizeMatrixPathSegment(params.userId);
const serverKey = resolveMatrixHomeserverKey(params.homeserver);
const tokenHash = hashMatrixAccessToken(params.accessToken);
return {
rootDir: path.join(
params.stateDir,
"matrix",
"accounts",
accountKey,
`${serverKey}__${userKey}`,
tokenHash,
),
accountKey,
tokenHash,
};
}

View File

@@ -7,7 +7,7 @@ import { writeJsonFileAtomically as writeJsonFileAtomicallyImpl } from "../plugi
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixLegacyFlatStoragePaths,
} from "../plugin-sdk/matrix.js";
} from "./matrix-config-helpers.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { resolveMatrixLegacyFlatStoragePaths } from "../plugin-sdk/matrix.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./matrix-config-helpers.js";
import { resolveLegacyMatrixFlatStoreTarget } from "./matrix-migration-config.js";
export type MatrixLegacyStateMigrationResult = {

View File

@@ -2,18 +2,17 @@ import fs from "node:fs";
import os from "node:os";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import {
findMatrixAccountEntry,
getMatrixScopedEnvVarNames,
requiresExplicitMatrixDefaultAccount,
resolveMatrixAccountStringValues,
resolveConfiguredMatrixAccountIds,
resolveMatrixAccountStorageRoot,
resolveMatrixChannelConfig,
resolveMatrixCredentialsPath,
resolveMatrixDefaultOrOnlyAccountId,
} from "../plugin-sdk/matrix.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
} from "./matrix-config-helpers.js";
export type MatrixStoredCredentials = {
homeserver: string;
@@ -37,10 +36,70 @@ export type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
type MatrixResolvedStringField =
| "homeserver"
| "userId"
| "accessToken"
| "password"
| "deviceId"
| "deviceName";
type MatrixResolvedStringValues = Record<MatrixResolvedStringField, string>;
type MatrixStringSourceMap = Partial<Record<MatrixResolvedStringField, string>>;
const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set<MatrixResolvedStringField>([
"userId",
"accessToken",
"password",
"deviceId",
]);
function clean(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveMatrixStringSourceValue(value: string | undefined): string {
return typeof value === "string" ? value : "";
}
function shouldAllowBaseAuthFallback(accountId: string, field: MatrixResolvedStringField): boolean {
return (
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID ||
!MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS.has(field)
);
}
function resolveMatrixAccountStringValues(params: {
accountId: string;
account?: MatrixStringSourceMap;
scopedEnv?: MatrixStringSourceMap;
channel?: MatrixStringSourceMap;
globalEnv?: MatrixStringSourceMap;
}): MatrixResolvedStringValues {
const fields: MatrixResolvedStringField[] = [
"homeserver",
"userId",
"accessToken",
"password",
"deviceId",
"deviceName",
];
const resolved = {} as MatrixResolvedStringValues;
for (const field of fields) {
resolved[field] =
resolveMatrixStringSourceValue(params.account?.[field]) ||
resolveMatrixStringSourceValue(params.scopedEnv?.[field]) ||
(shouldAllowBaseAuthFallback(params.accountId, field)
? resolveMatrixStringSourceValue(params.channel?.[field]) ||
resolveMatrixStringSourceValue(params.globalEnv?.[field])
: "");
}
return resolved;
}
function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv,

View File

@@ -121,6 +121,13 @@ function expectSourceOmitsSnippet(subpath: string, snippet: string) {
expect(readPluginSdkSource(subpath)).not.toContain(snippet);
}
function expectSourceOmitsImportPattern(subpath: string, specifier: string) {
const escapedSpecifier = specifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const source = readPluginSdkSource(subpath);
expect(source).not.toMatch(new RegExp(`\\bfrom\\s+["']${escapedSpecifier}["']`, "u"));
expect(source).not.toMatch(new RegExp(`\\bimport\\(\\s*["']${escapedSpecifier}["']\\s*\\)`, "u"));
}
describe("plugin-sdk subpath exports", () => {
it("keeps the curated public list free of internal implementation subpaths", () => {
for (const deniedSubpath of [
@@ -613,8 +620,8 @@ describe("plugin-sdk subpath exports", () => {
],
});
expectSourceOmitsSnippet("provider-setup", "./ollama-surface.js");
expectSourceOmitsSnippet("provider-setup", "./vllm.js");
expectSourceOmitsSnippet("provider-setup", "./sglang.js");
expectSourceOmitsImportPattern("provider-setup", "./vllm.js");
expectSourceOmitsImportPattern("provider-setup", "./sglang.js");
expectSourceMentions("provider-auth", [
"buildOauthProviderAuthResult",
"generatePkceVerifierChallenge",
@@ -665,6 +672,8 @@ describe("plugin-sdk subpath exports", () => {
],
omits: ["buildVllmProvider", "buildSglangProvider"],
});
expectSourceOmitsImportPattern("self-hosted-provider-setup", "./vllm.js");
expectSourceOmitsImportPattern("self-hosted-provider-setup", "./sglang.js");
expectSourceOmitsSnippet("agent-runtime", "./sglang.js");
expectSourceOmitsSnippet("agent-runtime", "./vllm.js");
expectSourceOmitsSnippet("xai-model-id", "./xai.js");

View File

@@ -78,6 +78,15 @@ describe("bundled plugin metadata", () => {
);
});
it("loads tlon channel config metadata from the lightweight schema surface", () => {
const tlon = listBundledPluginMetadata().find((entry) => entry.dirName === "tlon");
expect(tlon?.manifest.channelConfigs?.tlon).toEqual(
expect.objectContaining({
schema: expect.objectContaining({ type: "object" }),
}),
);
});
it("excludes test-only public surface artifacts", () => {
listBundledPluginMetadata().forEach((entry) =>
expectTestOnlyArtifactsExcluded(entry.publicSurfaceArtifacts ?? []),
@@ -234,6 +243,10 @@ describe("bundled plugin metadata", () => {
});
writeJson(path.join(distRoot, "extensions", "alpha", "openclaw.plugin.json"), {
id: "alpha",
configSchema: {
type: "object",
properties: {},
},
channels: ["alpha"],
channelConfigs: {
alpha: {

View File

@@ -4,6 +4,7 @@ import path from "node:path";
const BROWSER_FIXTURE_MANIFEST = {
id: "browser",
enabledByDefault: true,
configSchema: {
type: "object",
additionalProperties: false,

View File

@@ -85,7 +85,6 @@ describe("buildOfficialChannelCatalog", () => {
},
install: {
npmSpec: "@openclaw/whatsapp",
localPath: bundledPluginRoot("whatsapp"),
defaultChoice: "npm",
},
},