From 855878b4f085750e367b31abb46247c397a50b66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 04:45:09 +0900 Subject: [PATCH] fix: stabilize serial test suite --- .../src/bot-message-context.test-harness.ts | 2 +- extensions/tlon/src/config-schema.ts | 2 +- .../read-only-account-inspect.telegram.ts | 286 ++++++++++++++++++ src/channels/read-only-account-inspect.ts | 4 +- src/cli/daemon-cli/status.gather.test.ts | 1 + src/commands/doctor-config-flow.test.ts | 2 +- src/commands/doctor/providers/matrix.test.ts | 35 ++- src/commands/doctor/providers/matrix.ts | 46 ++- src/commands/doctor/providers/telegram.ts | 28 +- src/config/plugin-auto-enable.test.ts | 30 ++ src/config/plugin-auto-enable.ts | 70 +++-- src/image-generation/runtime.test.ts | 18 ++ .../heartbeat-runner.test-channel-plugins.ts | 96 ++++++ src/infra/heartbeat-runner.test-harness.ts | 39 +-- src/infra/heartbeat-runner.test-utils.ts | 15 +- src/infra/matrix-config-helpers.ts | 264 ++++++++++++++++ src/infra/matrix-legacy-crypto.ts | 2 +- src/infra/matrix-legacy-state.ts | 2 +- src/infra/matrix-migration-config.ts | 65 +++- src/plugin-sdk/subpaths.test.ts | 13 +- src/plugins/bundled-plugin-metadata.test.ts | 13 + .../helpers/browser-bundled-plugin-fixture.ts | 1 + test/official-channel-catalog.test.ts | 1 - 23 files changed, 942 insertions(+), 93 deletions(-) create mode 100644 src/channels/read-only-account-inspect.telegram.ts create mode 100644 src/infra/heartbeat-runner.test-channel-plugins.ts create mode 100644 src/infra/matrix-config-helpers.ts diff --git a/extensions/telegram/src/bot-message-context.test-harness.ts b/extensions/telegram/src/bot-message-context.test-harness.ts index bc2e7bd48e0..150683aa07f 100644 --- a/extensions/telegram/src/bot-message-context.test-harness.ts +++ b/extensions/telegram/src/bot-message-context.test-harness.ts @@ -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> > { + const { vi } = await import("vitest"); const { buildTelegramMessageContext } = await import("./bot-message-context.js"); return await buildTelegramMessageContext({ primaryCtx: { diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 8ac8e829a86..d170631540f 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -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); diff --git a/src/channels/read-only-account-inspect.telegram.ts b/src/channels/read-only-account-inspect.telegram.ts new file mode 100644 index 00000000000..dda404a48ab --- /dev/null +++ b/src/channels/read-only-account-inspect.telegram.ts @@ -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(); + 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), + }); +} diff --git a/src/channels/read-only-account-inspect.ts b/src/channels/read-only-account-inspect.ts index d26c1c77f55..de5bcccbeb5 100644 --- a/src/channels/read-only-account-inspect.ts +++ b/src/channels/read-only-account-inspect.ts @@ -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 | undefined; let slackInspectModulePromise: Promise | 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; } diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index f6ad1e9ae41..3b971413103 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -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), diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index c5c7ce6a40f..d55f7cf440e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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"; diff --git a/src/commands/doctor/providers/matrix.test.ts b/src/commands/doctor/providers/matrix.test.ts index e4bc2e4ce7c..ab859d065b3 100644 --- a/src/commands/doctor/providers/matrix.test.ts +++ b/src/commands/doctor/providers/matrix.test.ts @@ -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(); + } + }); }); diff --git a/src/commands/doctor/providers/matrix.ts b/src/commands/doctor/providers/matrix.ts index 0f13843160e..6acf01c5e12 100644 --- a/src/commands/doctor/providers/matrix.ts +++ b/src/commands/doctor/providers/matrix.ts @@ -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 | 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 }; } diff --git a/src/commands/doctor/providers/telegram.ts b/src/commands/doctor/providers/telegram.ts index df352fdec21..580e91a93f5 100644 --- a/src/commands/doctor/providers/telegram.ts +++ b/src/commands/doctor/providers/telegram.ts @@ -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(); for (const accountId of listTelegramAccountIds(resolvedConfig)) { - let account: NonNullable>; + let inspected: ReturnType; 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; diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index bf8e4798a01..7d03bc2aedf 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -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: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 9a18731bf89..87406167835 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -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(listKnownChannelPluginIds()); - const configuredChannels = cfg.channels as Record | 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 | 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) diff --git a/src/image-generation/runtime.test.ts b/src/image-generation/runtime.test.ts index 7027eab1f84..a830864e597 100644 --- a/src/image-generation/runtime.test.ts +++ b/src/image-generation/runtime.test.ts @@ -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, + _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" }), diff --git a/src/infra/heartbeat-runner.test-channel-plugins.ts b/src/infra/heartbeat-runner.test-channel-plugins.ts new file mode 100644 index 00000000000..fa7fbce2ddc --- /dev/null +++ b/src/infra/heartbeat-runner.test-channel-plugins.ts @@ -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, +) => Promise>; + +function createHeartbeatOutboundAdapter(channelId: HeartbeatSendChannelId): ChannelOutboundAdapter { + return { + deliveryMode: "direct", + sendText: async ({ to, text, deps, cfg, accountId, replyToId, threadId, ...opts }) => { + const send = resolveOutboundSendDep(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" }; + }, + }, +}); diff --git a/src/infra/heartbeat-runner.test-harness.ts b/src/infra/heartbeat-runner.test-harness.ts index 682b65f3b37..6137f9555ed 100644 --- a/src/infra/heartbeat-runner.test-harness.ts +++ b/src/infra/heartbeat-runner.test-harness.ts @@ -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" }, ]), ); }); diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index 626c803f091..a3832ae0c6a 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -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( } export function setupTelegramHeartbeatPluginRuntimeForTests() { - const runtime = createPluginRuntime(); - setTelegramRuntime(runtime); setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "telegram", plugin: heartbeatRunnerTelegramPlugin, source: "test" }, + ]), ); } diff --git a/src/infra/matrix-config-helpers.ts b/src/infra/matrix-config-helpers.ts new file mode 100644 index 00000000000..3ce29c9e371 --- /dev/null +++ b/src/infra/matrix-config-helpers.ts @@ -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 { + 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 | null { + return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null; +} + +export function findMatrixAccountEntry( + cfg: OpenClawConfig, + accountId: string, +): Record | 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(); + 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, + }; +} diff --git a/src/infra/matrix-legacy-crypto.ts b/src/infra/matrix-legacy-crypto.ts index 7fd6acb5581..e64251e27ae 100644 --- a/src/infra/matrix-legacy-crypto.ts +++ b/src/infra/matrix-legacy-crypto.ts @@ -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, diff --git a/src/infra/matrix-legacy-state.ts b/src/infra/matrix-legacy-state.ts index cd800526b9b..d40d547654f 100644 --- a/src/infra/matrix-legacy-state.ts +++ b/src/infra/matrix-legacy-state.ts @@ -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 = { diff --git a/src/infra/matrix-migration-config.ts b/src/infra/matrix-migration-config.ts index e55e64a4d91..7eb24e80452 100644 --- a/src/infra/matrix-migration-config.ts +++ b/src/infra/matrix-migration-config.ts @@ -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; + +type MatrixStringSourceMap = Partial>; + +const MATRIX_DEFAULT_ACCOUNT_AUTH_ONLY_FIELDS = new Set([ + "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, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 67effaffceb..3f9f97da180 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -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"); diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 74fc308f407..b041020c57a 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -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: { diff --git a/test/helpers/browser-bundled-plugin-fixture.ts b/test/helpers/browser-bundled-plugin-fixture.ts index 2cb3bdaa5f5..c54fdf15688 100644 --- a/test/helpers/browser-bundled-plugin-fixture.ts +++ b/test/helpers/browser-bundled-plugin-fixture.ts @@ -4,6 +4,7 @@ import path from "node:path"; const BROWSER_FIXTURE_MANIFEST = { id: "browser", + enabledByDefault: true, configSchema: { type: "object", additionalProperties: false, diff --git a/test/official-channel-catalog.test.ts b/test/official-channel-catalog.test.ts index a4eb9cbc5a0..594f5a34b6a 100644 --- a/test/official-channel-catalog.test.ts +++ b/test/official-channel-catalog.test.ts @@ -85,7 +85,6 @@ describe("buildOfficialChannelCatalog", () => { }, install: { npmSpec: "@openclaw/whatsapp", - localPath: bundledPluginRoot("whatsapp"), defaultChoice: "npm", }, },