diff --git a/extensions/mattermost/gateway-auth-api.ts b/extensions/mattermost/gateway-auth-api.ts new file mode 100644 index 00000000000..870a923528a --- /dev/null +++ b/extensions/mattermost/gateway-auth-api.ts @@ -0,0 +1 @@ +export { resolveMattermostGatewayAuthBypassPaths as resolveGatewayAuthBypassPaths } from "./src/gateway-auth-bypass.js"; diff --git a/extensions/mattermost/src/channel-config-shared.ts b/extensions/mattermost/src/channel-config-shared.ts index 8022ba0ae47..4b97595f4a7 100644 --- a/extensions/mattermost/src/channel-config-shared.ts +++ b/extensions/mattermost/src/channel-config-shared.ts @@ -5,14 +5,16 @@ import { createScopedChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + collectMattermostSlashCallbackPaths, + resolveMattermostGatewayAuthBypassPaths, +} from "./gateway-auth-bypass.js"; import { listMattermostAccountIds, resolveDefaultMattermostAccountId, resolveMattermostAccount, type ResolvedMattermostAccount, } from "./mattermost/accounts.js"; -import type { MattermostSlashCommandConfig } from "./mattermost/slash-commands.js"; -import type { MattermostConfig } from "./types.js"; export const mattermostMeta = { id: "mattermost", @@ -27,8 +29,6 @@ export const mattermostMeta = { quickstartAllowFrom: true, } as const; -const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; - export function normalizeMattermostAllowEntry(entry: string): string { return normalizeLowercaseStringOrEmpty( entry @@ -50,62 +50,7 @@ export function formatMattermostAllowEntry(entry: string): string { return normalizeLowercaseStringOrEmpty(trimmed.replace(/^(mattermost|user):/i, "")); } -export function collectMattermostSlashCallbackPaths( - raw?: Partial, -): string[] { - const callbackPath = (() => { - const trimmed = raw?.callbackPath?.trim(); - if (!trimmed) { - return DEFAULT_SLASH_CALLBACK_PATH; - } - return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; - })(); - const callbackUrl = raw?.callbackUrl?.trim(); - const paths = new Set([callbackPath]); - if (callbackUrl) { - try { - const pathname = new URL(callbackUrl).pathname; - if (pathname) { - paths.add(pathname); - } - } catch { - // Keep the normalized callback path when the configured URL is invalid. - } - } - return [...paths]; -} - -export function resolveMattermostGatewayAuthBypassPaths(cfg: { - channels?: Record; -}): string[] { - const base = cfg.channels?.mattermost as MattermostConfig | undefined; - const callbackPaths = new Set( - collectMattermostSlashCallbackPaths( - base?.commands as Partial | undefined, - ).filter( - (path) => - path === "/api/channels/mattermost/command" || path.startsWith("/api/channels/mattermost/"), - ), - ); - const accounts = base?.accounts ?? {}; - for (const account of Object.values(accounts)) { - const accountConfig = - account && typeof account === "object" && !Array.isArray(account) - ? (account as { - commands?: Parameters[0]; - }) - : undefined; - for (const path of collectMattermostSlashCallbackPaths(accountConfig?.commands)) { - if ( - path === "/api/channels/mattermost/command" || - path.startsWith("/api/channels/mattermost/") - ) { - callbackPaths.add(path); - } - } - } - return [...callbackPaths]; -} +export { collectMattermostSlashCallbackPaths, resolveMattermostGatewayAuthBypassPaths }; export const mattermostConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: "mattermost", diff --git a/extensions/mattermost/src/gateway-auth-bypass.test.ts b/extensions/mattermost/src/gateway-auth-bypass.test.ts new file mode 100644 index 00000000000..a1b31a4761b --- /dev/null +++ b/extensions/mattermost/src/gateway-auth-bypass.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + collectMattermostSlashCallbackPaths, + resolveMattermostGatewayAuthBypassPaths, +} from "./gateway-auth-bypass.js"; + +describe("Mattermost gateway auth bypass paths", () => { + it("normalizes slash callback paths and callback URL paths", () => { + expect( + collectMattermostSlashCallbackPaths({ + callbackPath: "api/channels/mattermost/command", + callbackUrl: "https://gateway.example.com/api/channels/mattermost/custom", + }), + ).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/custom"]); + }); + + it("keeps only Mattermost channel callback paths", () => { + expect( + resolveMattermostGatewayAuthBypassPaths({ + channels: { + mattermost: { + commands: { + callbackPath: "/api/channels/mattermost/command", + callbackUrl: "https://gateway.example.com/api/channels/nostr/default/profile", + }, + accounts: { + work: { + commands: { + callbackPath: "/api/channels/mattermost/work", + }, + }, + }, + }, + }, + }), + ).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/work"]); + }); +}); diff --git a/extensions/mattermost/src/gateway-auth-bypass.ts b/extensions/mattermost/src/gateway-auth-bypass.ts new file mode 100644 index 00000000000..9dda0ba0b48 --- /dev/null +++ b/extensions/mattermost/src/gateway-auth-bypass.ts @@ -0,0 +1,83 @@ +const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command"; + +type MattermostSlashCommandConfigInput = { + callbackPath?: unknown; + callbackUrl?: unknown; +}; + +type MattermostAccountConfigInput = { + commands?: MattermostSlashCommandConfigInput; +}; + +type MattermostConfigInput = MattermostAccountConfigInput & { + accounts?: Record; +}; + +function readTrimmedString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function normalizeCallbackPath(value: unknown): string { + const trimmed = readTrimmedString(value); + if (!trimmed) { + return DEFAULT_SLASH_CALLBACK_PATH; + } + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} + +function readMattermostCommands(value: unknown): MattermostSlashCommandConfigInput | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as MattermostSlashCommandConfigInput) + : undefined; +} + +function isMattermostBypassPath(path: string): boolean { + return path === DEFAULT_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/"); +} + +export function collectMattermostSlashCallbackPaths( + raw?: MattermostSlashCommandConfigInput, +): string[] { + const paths = new Set([normalizeCallbackPath(raw?.callbackPath)]); + const callbackUrl = readTrimmedString(raw?.callbackUrl); + if (callbackUrl) { + try { + const pathname = new URL(callbackUrl).pathname; + if (pathname) { + paths.add(pathname); + } + } catch { + // Keep the normalized callback path when the configured URL is invalid. + } + } + return [...paths]; +} + +export function resolveMattermostGatewayAuthBypassPaths(cfg: { + channels?: Record; +}): string[] { + const base = + cfg.channels?.mattermost && typeof cfg.channels.mattermost === "object" + ? (cfg.channels.mattermost as MattermostConfigInput) + : undefined; + const callbackPaths = new Set( + collectMattermostSlashCallbackPaths(readMattermostCommands(base?.commands)).filter( + isMattermostBypassPath, + ), + ); + const accounts = base?.accounts ?? {}; + for (const account of Object.values(accounts)) { + const accountConfig = + account && typeof account === "object" && !Array.isArray(account) + ? (account as MattermostAccountConfigInput) + : undefined; + for (const path of collectMattermostSlashCallbackPaths( + readMattermostCommands(accountConfig?.commands), + )) { + if (isMattermostBypassPath(path)) { + callbackPaths.add(path); + } + } + } + return [...callbackPaths]; +} diff --git a/src/channels/plugins/gateway-auth-bypass.test.ts b/src/channels/plugins/gateway-auth-bypass.test.ts new file mode 100644 index 00000000000..273f5caba54 --- /dev/null +++ b/src/channels/plugins/gateway-auth-bypass.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; + +const { loadBundledPluginPublicArtifactModuleSyncMock } = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "mattermost" && artifactBasename === "gateway-auth-api.js") { + return { + resolveGatewayAuthBypassPaths: () => [ + " /api/channels/mattermost/command ", + "", + null, + "/api/channels/mattermost/work", + ], + }; + } + if (dirName === "broken" && artifactBasename === "gateway-auth-api.js") { + throw new Error("broken gateway auth artifact"); + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); + +vi.mock("../../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); + +import { resolveBundledChannelGatewayAuthBypassPaths } from "./gateway-auth-bypass.js"; + +describe("bundled channel gateway auth bypass fast path", () => { + it("loads the narrow gateway auth artifact for configured channels", () => { + const paths = resolveBundledChannelGatewayAuthBypassPaths({ + channelId: "mattermost", + cfg: { channels: { mattermost: {} } }, + }); + + expect(paths).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/work"]); + expect(loadBundledPluginPublicArtifactModuleSyncMock).toHaveBeenCalledWith({ + dirName: "mattermost", + artifactBasename: "gateway-auth-api.js", + }); + }); + + it("treats missing gateway auth artifacts as no bypass paths", () => { + expect( + resolveBundledChannelGatewayAuthBypassPaths({ + channelId: "discord", + cfg: { channels: { discord: {} } }, + }), + ).toEqual([]); + }); + + it("surfaces errors from present gateway auth artifacts", () => { + expect(() => + resolveBundledChannelGatewayAuthBypassPaths({ + channelId: "broken", + cfg: { channels: { broken: {} } }, + }), + ).toThrow("broken gateway auth artifact"); + }); +}); diff --git a/src/channels/plugins/gateway-auth-bypass.ts b/src/channels/plugins/gateway-auth-bypass.ts new file mode 100644 index 00000000000..9d51b480c58 --- /dev/null +++ b/src/channels/plugins/gateway-auth-bypass.ts @@ -0,0 +1,32 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../../plugins/public-surface-loader.js"; + +type GatewayAuthBypassApi = { + resolveGatewayAuthBypassPaths?: (params: { cfg: OpenClawConfig }) => readonly unknown[]; +}; + +const GATEWAY_AUTH_API_ARTIFACT_BASENAME = "gateway-auth-api.js"; +const MISSING_PUBLIC_SURFACE_PREFIX = "Unable to resolve bundled plugin public surface "; + +function loadBundledChannelGatewayAuthApi(channelId: string): GatewayAuthBypassApi | undefined { + try { + return loadBundledPluginPublicArtifactModuleSync({ + dirName: channelId, + artifactBasename: GATEWAY_AUTH_API_ARTIFACT_BASENAME, + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith(MISSING_PUBLIC_SURFACE_PREFIX)) { + return undefined; + } + throw error; + } +} + +export function resolveBundledChannelGatewayAuthBypassPaths(params: { + channelId: string; + cfg: OpenClawConfig; +}): string[] { + const api = loadBundledChannelGatewayAuthApi(params.channelId); + const paths = api?.resolveGatewayAuthBypassPaths?.({ cfg: params.cfg }) ?? []; + return paths.flatMap((path) => (typeof path === "string" && path.trim() ? [path.trim()] : [])); +} diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 63891bc7301..6241bf4bcd7 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -335,6 +335,7 @@ export type ChannelLogoutContext = { export type ChannelGatewayAdapter = { startAccount?: (ctx: ChannelGatewayContext) => Promise; stopAccount?: (ctx: ChannelGatewayContext) => Promise; + /** Keep gateway auth bypass resolution mirrored through a lightweight top-level `gateway-auth-api.ts` artifact. */ resolveGatewayAuthBypassPaths?: (params: { cfg: OpenClawConfig }) => string[]; loginWithQrStart?: (params: { accountId?: string; diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 8c8484d2232..396e8b510eb 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -10,6 +10,7 @@ import type { TlsOptions } from "node:tls"; import type { WebSocketServer } from "ws"; import { A2UI_PATH, CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; +import { resolveBundledChannelGatewayAuthBypassPaths } from "../channels/plugins/gateway-auth-bypass.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; @@ -77,9 +78,6 @@ type SubsystemLogger = ReturnType; const HOOK_AUTH_FAILURE_LIMIT = 20; const HOOK_AUTH_FAILURE_WINDOW_MS = 60_000; -let bundledChannelsModulePromise: - | Promise - | undefined; let identityAvatarModulePromise: Promise | undefined; let controlUiModulePromise: Promise | undefined; let embeddingsHttpModulePromise: Promise | undefined; @@ -92,11 +90,6 @@ let sessionHistoryHttpModulePromise: let sessionKillHttpModulePromise: Promise | undefined; let toolsInvokeHttpModulePromise: Promise | undefined; -function getBundledChannelsModule() { - bundledChannelsModulePromise ??= import("../channels/plugins/bundled.js"); - return bundledChannelsModulePromise; -} - function getIdentityAvatarModule() { identityAvatarModulePromise ??= import("../agents/identity-avatar.js"); return identityAvatarModulePromise; @@ -202,21 +195,12 @@ async function resolvePluginGatewayAuthBypassPaths( if (!configuredChannels || Object.keys(configuredChannels).length === 0) { return paths; } - const { getBundledChannelPlugin, getBundledChannelSetupPlugin } = - await getBundledChannelsModule(); for (const channelId of Object.keys(configuredChannels)) { - const setupPlugin = getBundledChannelSetupPlugin(channelId); - const plugin = setupPlugin?.gateway?.resolveGatewayAuthBypassPaths - ? setupPlugin - : getBundledChannelPlugin(channelId); - if (!plugin) { - continue; - } - for (const path of plugin.gateway?.resolveGatewayAuthBypassPaths?.({ cfg: configSnapshot }) ?? - []) { - if (typeof path === "string" && path.trim()) { - paths.add(path.trim()); - } + for (const path of resolveBundledChannelGatewayAuthBypassPaths({ + channelId, + cfg: configSnapshot, + })) { + paths.add(path); } } return paths;