From 201385548c8a0287661ba32f96ea55b96daf7cba Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Wed, 22 Apr 2026 16:42:43 +1000 Subject: [PATCH] perf(slack): narrow runtime-setter + lazy-load 4 modules + narrow 2 SDK surfaces (#69317) Lazy load modules showing a ~50% gateway startup performance improvement --- extensions/slack/channel-entry.ts | 2 +- extensions/slack/http-routes-api.ts | 4 + extensions/slack/index.ts | 4 +- extensions/slack/runtime-setter-api.ts | 3 + .../slack/src/channel.lazy-seams.test.ts | 318 ++++++++++++++++++ extensions/slack/src/channel.ts | 97 +++++- extensions/slack/src/security.ts | 9 +- .../slack/src/setup-core.lazy-proxy.test.ts | 125 +++++++ .../lib/bundled-runtime-sidecar-paths.json | 1 + src/plugins/bundled-plugin-metadata.test.ts | 11 + .../plugin-sdk-runtime-api-guardrails.test.ts | 17 + 11 files changed, 574 insertions(+), 17 deletions(-) create mode 100644 extensions/slack/http-routes-api.ts create mode 100644 extensions/slack/runtime-setter-api.ts create mode 100644 extensions/slack/src/channel.lazy-seams.test.ts create mode 100644 extensions/slack/src/setup-core.lazy-proxy.test.ts diff --git a/extensions/slack/channel-entry.ts b/extensions/slack/channel-entry.ts index 95eed34ee86..6a01736e52d 100644 --- a/extensions/slack/channel-entry.ts +++ b/extensions/slack/channel-entry.ts @@ -14,7 +14,7 @@ export default defineBundledChannelEntry({ exportName: "channelSecrets", }, runtime: { - specifier: "./runtime-api.js", + specifier: "./runtime-setter-api.js", exportName: "setSlackRuntime", }, }); diff --git a/extensions/slack/http-routes-api.ts b/extensions/slack/http-routes-api.ts new file mode 100644 index 00000000000..56959581ddb --- /dev/null +++ b/extensions/slack/http-routes-api.ts @@ -0,0 +1,4 @@ +// Narrow entry point for registerSlackPluginHttpRoutes — avoids pulling in +// the full runtime-api barrel (~284KB, 13 chunks) during plugin register(). +// Mirrors the runtime-setter-api.ts split. +export { registerSlackPluginHttpRoutes } from "./src/http/plugin-routes.js"; diff --git a/extensions/slack/index.ts b/extensions/slack/index.ts index 7f6082f4ef8..fed5843b0da 100644 --- a/extensions/slack/index.ts +++ b/extensions/slack/index.ts @@ -6,7 +6,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/channel-entry-contra function registerSlackPluginHttpRoutes(api: OpenClawPluginApi): void { const register = loadBundledEntryExportSync<(api: OpenClawPluginApi) => void>(import.meta.url, { - specifier: "./runtime-api.js", + specifier: "./http-routes-api.js", exportName: "registerSlackPluginHttpRoutes", }); register(api); @@ -26,7 +26,7 @@ export default defineBundledChannelEntry({ exportName: "channelSecrets", }, runtime: { - specifier: "./runtime-api.js", + specifier: "./runtime-setter-api.js", exportName: "setSlackRuntime", }, accountInspect: { diff --git a/extensions/slack/runtime-setter-api.ts b/extensions/slack/runtime-setter-api.ts new file mode 100644 index 00000000000..3dc8b147c57 --- /dev/null +++ b/extensions/slack/runtime-setter-api.ts @@ -0,0 +1,3 @@ +// Narrow entry point for setSlackRuntime — avoids pulling in the full +// runtime-api barrel (284KB, 29 chunks) during plugin register(). +export { setSlackRuntime } from "./src/runtime.js"; diff --git a/extensions/slack/src/channel.lazy-seams.test.ts b/extensions/slack/src/channel.lazy-seams.test.ts new file mode 100644 index 00000000000..e25f21672cf --- /dev/null +++ b/extensions/slack/src/channel.lazy-seams.test.ts @@ -0,0 +1,318 @@ +// Regression tests for the lazy-loading boundaries introduced for Slack +// startup-perf work (see PR #69317). Each test asserts both: +// - that the lazy module is reached (call mocks fire), and +// - that the inputs forwarded into the lazy module are correct, and +// - that the lazy module's return value is propagated back through the +// plugin surface unchanged. +// +// Together these guard against: +// - dynamic-import path/specifier drift on cold paths, +// - silent contract drift between the channel and its lazy modules, +// - and accidental loss of the perf intent (re-introducing eager imports +// without updating the seam). + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { slackPlugin } from "./channel.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { setSlackRuntime } from "./runtime.js"; + +// --- Hoisted mocks for lazy seams ------------------------------------------------ + +const collectAuditFindingsMock = vi.hoisted(() => vi.fn()); +const fetchSlackScopesMock = vi.hoisted(() => vi.fn()); +const resolveTargetsWithOptionalTokenMock = vi.hoisted(() => vi.fn()); +const buildPassiveProbedChannelStatusSummaryMock = vi.hoisted(() => vi.fn()); +vi.mock("./security-audit.js", () => ({ + collectSlackSecurityAuditFindings: collectAuditFindingsMock, +})); + +vi.mock("./scopes.js", () => ({ + fetchSlackScopes: fetchSlackScopesMock, +})); + +vi.mock("openclaw/plugin-sdk/target-resolver-runtime", async (orig) => { + // Preserve any sibling exports so importers that touch unrelated helpers + // do not break; only override the function the channel actually calls. + const original = (await orig()) as Record; + return { + ...original, + resolveTargetsWithOptionalToken: resolveTargetsWithOptionalTokenMock, + }; +}); + +vi.mock("openclaw/plugin-sdk/extension-shared", async (orig) => { + const original = (await orig()) as Record; + return { + ...original, + buildPassiveProbedChannelStatusSummary: buildPassiveProbedChannelStatusSummaryMock, + }; +}); + +// --- Test setup ----------------------------------------------------------------- + +beforeEach(() => { + collectAuditFindingsMock.mockReset(); + fetchSlackScopesMock.mockReset(); + resolveTargetsWithOptionalTokenMock.mockReset(); + buildPassiveProbedChannelStatusSummaryMock.mockReset(); + setSlackRuntime({ channel: { slack: {} } } as never); +}); + +function makeMinimalSlackConfig( + opts: { botToken?: string; userToken?: string } = {}, +): OpenClawConfig { + const slack: Record = {}; + if (opts.botToken !== undefined) { + slack.botToken = opts.botToken; + } + if (opts.userToken !== undefined) { + slack.userToken = opts.userToken; + } + return { channels: { slack } } as OpenClawConfig; +} + +// --- Status: buildChannelSummary ------------------------------------------------- + +describe("slackPlugin.status.buildChannelSummary lazy SDK forwarding", () => { + it("calls the lazy extension-shared SDK helper with the snapshot and token sources, and returns its output unchanged", async () => { + const buildChannelSummary = slackPlugin.status?.buildChannelSummary; + if (!buildChannelSummary) { + throw new Error("slackPlugin.status.buildChannelSummary should be exposed"); + } + + const sentinelSummary = { sentinel: "passive-summary" }; + buildPassiveProbedChannelStatusSummaryMock.mockReturnValue(sentinelSummary); + + const snapshot = { + accountId: "default", + configured: true, + enabled: true, + botTokenSource: "config" as const, + appTokenSource: "config" as const, + extra: { custom: 1 }, + }; + + const result = await buildChannelSummary({ + account: { accountId: "default" } as never, + snapshot, + cfg: makeMinimalSlackConfig({ botToken: "xoxb-test" }), + runtime: undefined, + } as never); + + expect(buildPassiveProbedChannelStatusSummaryMock).toHaveBeenCalledTimes(1); + const [forwardedSnapshot, forwardedExtras] = + buildPassiveProbedChannelStatusSummaryMock.mock.calls[0] ?? []; + // Snapshot must be forwarded by reference / structurally intact. + expect(forwardedSnapshot).toBe(snapshot); + // The channel must forward the (possibly fallback'd) token sources. + expect(forwardedExtras).toEqual({ botTokenSource: "config", appTokenSource: "config" }); + // The SDK return value must be propagated through unchanged. + expect(result).toBe(sentinelSummary); + }); + + it("falls back to 'none' for missing token sources before forwarding to the SDK helper", async () => { + const buildChannelSummary = slackPlugin.status?.buildChannelSummary; + if (!buildChannelSummary) { + throw new Error("slackPlugin.status.buildChannelSummary should be exposed"); + } + + buildPassiveProbedChannelStatusSummaryMock.mockReturnValue({ sentinel: true }); + + await buildChannelSummary({ + account: { accountId: "default" } as never, + snapshot: { accountId: "default", configured: false, enabled: true } as never, + cfg: makeMinimalSlackConfig(), + runtime: undefined, + } as never); + + const [, forwardedExtras] = buildPassiveProbedChannelStatusSummaryMock.mock.calls[0] ?? []; + expect(forwardedExtras).toEqual({ botTokenSource: "none", appTokenSource: "none" }); + }); +}); + +// --- Status: buildCapabilitiesDiagnostics --------------------------------------- + +describe("slackPlugin.status.buildCapabilitiesDiagnostics lazy scopes loader", () => { + it("invokes fetchSlackScopes once when only a bot token is present", async () => { + const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics; + if (!buildDiagnostics) { + throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed"); + } + + fetchSlackScopesMock.mockResolvedValue({ ok: true, scopes: ["chat:write"] }); + + const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" }); + const account = slackPlugin.config.resolveAccount(cfg, "default"); + const result = await buildDiagnostics({ account, timeoutMs: 1234, cfg } as never); + + expect(fetchSlackScopesMock).toHaveBeenCalledTimes(1); + expect(fetchSlackScopesMock).toHaveBeenCalledWith("xoxb-bot", 1234); + expect(result?.details).toMatchObject({ + botScopes: { ok: true, scopes: ["chat:write"] }, + }); + expect(result?.lines?.length ?? 0).toBeGreaterThan(0); + }); + + it("invokes fetchSlackScopes twice (bot and user) when both tokens are present", async () => { + const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics; + if (!buildDiagnostics) { + throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed"); + } + + fetchSlackScopesMock + .mockResolvedValueOnce({ ok: true, scopes: ["chat:write"] }) + .mockResolvedValueOnce({ ok: true, scopes: ["users:read"] }); + + const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" }); + const account = slackPlugin.config.resolveAccount(cfg, "default"); + const result = await buildDiagnostics({ account, timeoutMs: 5000, cfg } as never); + + expect(fetchSlackScopesMock).toHaveBeenCalledTimes(2); + expect(fetchSlackScopesMock.mock.calls[0]).toEqual(["xoxb-bot", 5000]); + expect(fetchSlackScopesMock.mock.calls[1]).toEqual(["xoxp-user", 5000]); + expect(result?.details).toMatchObject({ + botScopes: { ok: true, scopes: ["chat:write"] }, + userScopes: { ok: true, scopes: ["users:read"] }, + }); + }); + + it("does not invoke fetchSlackScopes when no bot token is present and reports a missing-token diagnostic", async () => { + const buildDiagnostics = slackPlugin.status?.buildCapabilitiesDiagnostics; + if (!buildDiagnostics) { + throw new Error("slackPlugin.status.buildCapabilitiesDiagnostics should be exposed"); + } + + const cfg = makeMinimalSlackConfig(); + const account = slackPlugin.config.resolveAccount(cfg, "default"); + const result = await buildDiagnostics({ account, timeoutMs: 1000, cfg } as never); + + expect(fetchSlackScopesMock).not.toHaveBeenCalled(); + expect(result?.details).toMatchObject({ + botScopes: { ok: false, error: "Slack bot token missing." }, + }); + }); +}); + +// --- Security: collectAuditFindings --------------------------------------------- + +describe("slackPlugin.security.collectAuditFindings lazy module forwarding", () => { + it("delegates to the lazy security-audit module with the original params and returns its output", async () => { + const collectAuditFindings = slackPlugin.security?.collectAuditFindings; + if (!collectAuditFindings) { + throw new Error("slackPlugin.security.collectAuditFindings should be exposed"); + } + + const sentinel = [ + { + checkId: "test-check", + severity: "info" as const, + title: "t", + detail: "d", + }, + ]; + collectAuditFindingsMock.mockResolvedValue(sentinel); + + const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" }); + const account = slackPlugin.config.resolveAccount(cfg, "default"); + const result = await collectAuditFindings({ cfg, accountId: "default", account } as never); + + expect(collectAuditFindingsMock).toHaveBeenCalledTimes(1); + expect(collectAuditFindingsMock.mock.calls[0]?.[0]).toEqual({ + cfg, + accountId: "default", + account, + }); + expect(result).toBe(sentinel); + }); + + it("propagates an empty findings array unchanged", async () => { + const collectAuditFindings = slackPlugin.security?.collectAuditFindings; + if (!collectAuditFindings) { + throw new Error("slackPlugin.security.collectAuditFindings should be exposed"); + } + + collectAuditFindingsMock.mockResolvedValue([]); + + const cfg = makeMinimalSlackConfig(); + const account = slackPlugin.config.resolveAccount(cfg, "default"); + const result = await collectAuditFindings({ cfg, account } as never); + + expect(result).toEqual([]); + }); +}); + +// --- Resolver: resolveTargets --------------------------------------------------- + +describe("slackPlugin.resolver.resolveTargets lazy SDK forwarding", () => { + it("forwards user inputs and the configured token to the lazy SDK helper and returns its output", async () => { + const resolveTargets = slackPlugin.resolver?.resolveTargets; + if (!resolveTargets) { + throw new Error("slackPlugin.resolver.resolveTargets should be exposed"); + } + + const sentinelOutput = [{ input: "U123", resolved: true, id: "U123", note: undefined }]; + resolveTargetsWithOptionalTokenMock.mockResolvedValue(sentinelOutput); + + const cfg = makeMinimalSlackConfig({ botToken: "xoxb-bot" }); + const result = await resolveTargets({ + cfg, + accountId: "default", + inputs: ["U123"], + kind: "user", + } as never); + + expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1); + const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? []; + expect(params).toMatchObject({ + token: "xoxb-bot", + inputs: ["U123"], + missingTokenNote: "missing Slack token", + }); + expect(typeof params.resolveWithToken).toBe("function"); + expect(typeof params.mapResolved).toBe("function"); + expect(result).toBe(sentinelOutput); + }); + + it("prefers the user token over the bot token when both are configured", async () => { + const resolveTargets = slackPlugin.resolver?.resolveTargets; + if (!resolveTargets) { + throw new Error("slackPlugin.resolver.resolveTargets should be exposed"); + } + + resolveTargetsWithOptionalTokenMock.mockResolvedValue([]); + + await resolveTargets({ + cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot", userToken: "xoxp-user" }), + accountId: "default", + inputs: ["U1"], + kind: "user", + } as never); + + const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? []; + expect(params).toMatchObject({ token: "xoxp-user" }); + }); + + it("uses the same lazy SDK helper for kind='group'", async () => { + const resolveTargets = slackPlugin.resolver?.resolveTargets; + if (!resolveTargets) { + throw new Error("slackPlugin.resolver.resolveTargets should be exposed"); + } + + resolveTargetsWithOptionalTokenMock.mockResolvedValue([]); + + await resolveTargets({ + cfg: makeMinimalSlackConfig({ botToken: "xoxb-bot" }), + accountId: "default", + inputs: ["C1"], + kind: "group", + } as never); + + expect(resolveTargetsWithOptionalTokenMock).toHaveBeenCalledTimes(1); + const [params] = resolveTargetsWithOptionalTokenMock.mock.calls[0] ?? []; + expect(params).toMatchObject({ token: "xoxb-bot", inputs: ["C1"] }); + }); +}); + +// Setup-wizard proxy delegation is unit-tested directly in +// setup-core.lazy-proxy.test.ts so it can be type-safe against the wider +// ChannelSetupWizard contract returned by createSlackSetupWizardProxy. diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 9846eef31d6..8028c84d3af 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -13,7 +13,6 @@ import { createChannelDirectoryAdapter, createRuntimeDirectoryLiveAdapter, } from "openclaw/plugin-sdk/directory-runtime"; -import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime"; import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; @@ -21,7 +20,6 @@ import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; -import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/target-resolver-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveDefaultSlackAccountId, @@ -51,14 +49,11 @@ import { isSlackInteractiveRepliesEnabled, } from "./interactive-replies.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; -import { slackOutbound } from "./outbound-adapter.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackReplyBlocks } from "./reply-blocks.js"; import { getOptionalSlackRuntime } from "./runtime.js"; -import { fetchSlackScopes } from "./scopes.js"; import { slackSecurityAdapter } from "./security.js"; -import { slackSetupAdapter } from "./setup-core.js"; -import { slackSetupWizard } from "./setup-surface.js"; +import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; import { createSlackPluginBase, isSlackPluginAccountConfigured, @@ -68,6 +63,70 @@ import { import { parseSlackTarget } from "./target-parsing.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; +// Lazy SDK loaders. The dynamic import is hidden behind a string-literal +// module id and typed by a hand-written structural alias so TypeScript does +// not have to crawl the SDK module's type graph just to type the loader. +// +// `openclaw/plugin-sdk/channel-policy` is intentionally NOT lazy here — +// `./group-policy.js` already imports it eagerly, so deferring it from +// `channel.ts` would not change the load graph. + +type ExtensionSharedSurface = { + buildPassiveProbedChannelStatusSummary: ( + snapshot: { + configured?: boolean; + running?: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + probe?: unknown; + lastProbeAt?: number | null; + }, + extra?: TExtra, + ) => { + configured: boolean; + running: boolean; + lastStartAt: number | null; + lastStopAt: number | null; + lastError: string | null; + probe: unknown; + lastProbeAt: number | null; + } & TExtra; +}; + +type TargetResolverRuntimeSurface = { + resolveTargetsWithOptionalToken: (params: { + token?: string | null; + inputs: string[]; + missingTokenNote: string; + resolveWithToken: (params: { token: string; inputs: string[] }) => Promise; + mapResolved: (entry: TResult) => { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; + }; + }) => Promise< + Array<{ input: string; resolved: boolean; id?: string; name?: string; note?: string }> + >; +}; + +const EXTENSION_SHARED_MODULE_ID = "openclaw/plugin-sdk/extension-shared"; +const TARGET_RESOLVER_RUNTIME_MODULE_ID = "openclaw/plugin-sdk/target-resolver-runtime"; + +const loadExtensionSharedSdk = createLazyRuntimeModule( + () => import(EXTENSION_SHARED_MODULE_ID) as Promise, +); +const loadTargetResolverRuntimeSdk = createLazyRuntimeModule( + () => import(TARGET_RESOLVER_RUNTIME_MODULE_ID) as Promise, +); + +const loadSlackSetupSurfaceModule = createLazyRuntimeModule(() => import("./setup-surface.js")); +const loadSlackScopesModule = createLazyRuntimeModule(() => import("./scopes.js")); +const loadSlackOutboundAdapterModule = createLazyRuntimeModule( + () => import("./outbound-adapter.js"), +); async function resolveSlackHandleAction() { return ( getOptionalSlackRuntime()?.channel?.slack?.handleSlackAction ?? @@ -258,9 +317,18 @@ async function resolveSlackOutboundSessionRoute(params: { }); } +// Mirrors `SlackScopesResult` in ./scopes.ts so the type does not pull the +// scopes module back in at module-load time. Keep the two in sync. +type SlackScopesResultShape = { + ok: boolean; + scopes?: string[]; + source?: string; + error?: string; +}; + function formatSlackScopeDiagnostic(params: { tokenType: "bot" | "user"; - result: Awaited>; + result: SlackScopesResultShape; }) { const source = params.result.source ? ` (${params.result.source})` : ""; const label = params.tokenType === "user" ? "User scopes" : "Bot scopes"; @@ -293,7 +361,7 @@ export const slackPlugin: ChannelPlugin = crea >({ base: { ...createSlackPluginBase({ - setupWizard: slackSetupWizard, + setupWizard: createSlackSetupWizardProxy(loadSlackSetupSurfaceModule), setup: slackSetupAdapter, }), allowlist: { @@ -379,6 +447,7 @@ export const slackPlugin: ChannelPlugin = crea note, }); const account = resolveSlackAccount({ cfg, accountId }); + const { resolveTargetsWithOptionalToken } = await loadTargetResolverRuntimeSdk(); if (kind === "group") { return resolveTargetsWithOptionalToken({ token: @@ -418,11 +487,13 @@ export const slackPlugin: ChannelPlugin = crea }), status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), - buildChannelSummary: ({ snapshot }) => - buildPassiveProbedChannelStatusSummary(snapshot, { + buildChannelSummary: async ({ snapshot }) => { + const { buildPassiveProbedChannelStatusSummary } = await loadExtensionSharedSdk(); + return buildPassiveProbedChannelStatusSummary(snapshot, { botTokenSource: snapshot.botTokenSource ?? "none", appTokenSource: snapshot.appTokenSource ?? "none", - }), + }); + }, probeAccount: async ({ account, timeoutMs }) => { const token = account.botToken?.trim(); if (!token) { @@ -447,7 +518,8 @@ export const slackPlugin: ChannelPlugin = crea const details: Record = {}; const botToken = account.botToken?.trim(); const userToken = account.config.userToken?.trim(); - const botScopes = botToken + const { fetchSlackScopes } = await loadSlackScopesModule(); + const botScopes: SlackScopesResultShape = botToken ? await fetchSlackScopes(botToken, timeoutMs) : { ok: false, error: "Slack bot token missing." }; lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes })); @@ -567,6 +639,7 @@ export const slackPlugin: ChannelPlugin = crea replyToId: ctx.replyToId, threadId: ctx.threadId, }); + const { slackOutbound } = await loadSlackOutboundAdapterModule(); return await slackOutbound.sendPayload!({ ...ctx, deps: { diff --git a/extensions/slack/src/security.ts b/extensions/slack/src/security.ts index 6360fc8d74e..b4630989c1e 100644 --- a/extensions/slack/src/security.ts +++ b/extensions/slack/src/security.ts @@ -1,8 +1,8 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { ResolvedSlackAccount } from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; -import { collectSlackSecurityAuditFindings } from "./security-audit.js"; const resolveSlackDmPolicy = createScopedDmSecurityResolver({ channelKey: "slack", @@ -36,8 +36,13 @@ const collectSlackSecurityWarnings = }, }); +const loadSlackSecurityAuditModule = createLazyRuntimeModule(() => import("./security-audit.js")); + export const slackSecurityAdapter = { resolveDmPolicy: resolveSlackDmPolicy, collectWarnings: collectSlackSecurityWarnings, - collectAuditFindings: collectSlackSecurityAuditFindings, + collectAuditFindings: async (params) => { + const { collectSlackSecurityAuditFindings } = await loadSlackSecurityAuditModule(); + return await collectSlackSecurityAuditFindings(params); + }, } satisfies NonNullable["security"]>; diff --git a/extensions/slack/src/setup-core.lazy-proxy.test.ts b/extensions/slack/src/setup-core.lazy-proxy.test.ts new file mode 100644 index 00000000000..3c518ebf957 --- /dev/null +++ b/extensions/slack/src/setup-core.lazy-proxy.test.ts @@ -0,0 +1,125 @@ +// Unit tests for createSlackSetupWizardProxy. The Slack channel plugin +// installs this proxy as setupWizard so the heavy ./setup-surface module is +// only imported when wizard methods that actually need it are invoked. +// +// These tests use a fake loader so the proxy can be tested type-safely +// against the wider ChannelSetupWizard contract, without going through the +// (narrower) ChannelPluginSetupWizard surface exposed on slackPlugin. + +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup-runtime"; +import { describe, expect, it, vi } from "vitest"; +import { createSlackSetupWizardProxy } from "./setup-core.js"; + +function makeFakeWizard(overrides: Partial = {}): ChannelSetupWizard { + return { + channel: "slack", + status: { + resolveConfigured: vi.fn(async () => ({ configured: false })), + }, + credentials: [], + ...overrides, + } as ChannelSetupWizard; +} + +describe("createSlackSetupWizardProxy", () => { + it("does not load the wizard module just by constructing the proxy", () => { + const loader = vi.fn(async () => ({ slackSetupWizard: makeFakeWizard() })); + const proxy = createSlackSetupWizardProxy(loader); + expect(proxy).toBeDefined(); + expect(loader).not.toHaveBeenCalled(); + }); + + it("forwards allowFrom.resolveEntries to the lazily loaded wizard and propagates its result", async () => { + const sentinel = [{ input: "U123", resolved: true, id: "U123" }]; + const resolveEntries = vi.fn(async () => sentinel); + // The full ChannelSetupWizardAllowFrom type carries many UI-only fields + // (placeholder, parseId, etc.) that are irrelevant to the proxy's + // delegation contract. Build a minimal stub and cast through unknown so + // the assertion stays focused on resolveEntries forwarding. + const fakeWizard = makeFakeWizard({ + allowFrom: { + resolveEntries, + } as unknown as ChannelSetupWizard["allowFrom"], + }); + const loader = vi.fn(async () => ({ slackSetupWizard: fakeWizard })); + const proxy = createSlackSetupWizardProxy(loader); + + const cfg = { channels: { slack: {} } } as never; + const result = await proxy.allowFrom!.resolveEntries({ + cfg, + accountId: "default", + credentialValues: { botToken: "xoxb-bot" }, + entries: ["U123"], + }); + + expect(loader).toHaveBeenCalledTimes(1); + expect(resolveEntries).toHaveBeenCalledTimes(1); + expect(resolveEntries).toHaveBeenCalledWith({ + cfg, + accountId: "default", + credentialValues: { botToken: "xoxb-bot" }, + entries: ["U123"], + }); + expect(result).toBe(sentinel); + }); + + it("returns unresolved entries without invoking the lazy wizard when allowFrom is absent on the loaded wizard", async () => { + const fakeWizard = makeFakeWizard(); + const loader = vi.fn(async () => ({ slackSetupWizard: fakeWizard })); + const proxy = createSlackSetupWizardProxy(loader); + + const result = await proxy.allowFrom!.resolveEntries({ + cfg: { channels: { slack: {} } } as never, + accountId: "default", + credentialValues: {}, + entries: ["U1", "U2"], + }); + + // The proxy still loads the wizard once to inspect its allowFrom shape, + // then falls back to a "resolved: false" projection of the inputs. + expect(loader).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { input: "U1", resolved: false, id: null }, + { input: "U2", resolved: false, id: null }, + ]); + }); + + it("forwards groupAccess.resolveAllowlist when present and uses the configured fallback otherwise", async () => { + // First: with groupAccess present, the lazy wizard handles resolution. + const groupResolved = ["G1-resolved"]; + const resolveAllowlist = vi.fn(async () => groupResolved); + const fakeWithGroupAccess = makeFakeWizard({ + groupAccess: { + resolveAllowlist, + } as unknown as ChannelSetupWizard["groupAccess"], + }); + const loaderA = vi.fn(async () => ({ slackSetupWizard: fakeWithGroupAccess })); + const proxyA = createSlackSetupWizardProxy(loaderA); + + const cfg = { channels: { slack: {} } } as never; + const a = await proxyA.groupAccess!.resolveAllowlist!({ + cfg, + accountId: "default", + credentialValues: {}, + entries: ["G1"], + prompter: undefined as never, + }); + expect(resolveAllowlist).toHaveBeenCalledTimes(1); + expect(a).toBe(groupResolved); + + // Second: without groupAccess, the fallback (entries -> entries) is used. + const fakeNoGroupAccess = makeFakeWizard(); + const loaderB = vi.fn(async () => ({ slackSetupWizard: fakeNoGroupAccess })); + const proxyB = createSlackSetupWizardProxy(loaderB); + + const b = await proxyB.groupAccess!.resolveAllowlist!({ + cfg, + accountId: "default", + credentialValues: {}, + entries: ["G1", "G2"], + prompter: undefined as never, + }); + // createSlackSetupWizardProxy passes (entries) => entries as the fallback. + expect(b).toEqual(["G1", "G2"]); + }); +}); diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index ab49f38ce07..c6a69ab43ad 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -28,6 +28,7 @@ "dist/extensions/qqbot/runtime-api.js", "dist/extensions/signal/runtime-api.js", "dist/extensions/slack/runtime-api.js", + "dist/extensions/slack/runtime-setter-api.js", "dist/extensions/telegram/runtime-api.js", "dist/extensions/telegram/runtime-setter-api.js", "dist/extensions/tlon/runtime-api.js", diff --git a/src/plugins/bundled-plugin-metadata.test.ts b/src/plugins/bundled-plugin-metadata.test.ts index 4f6cdf3c669..fb28829abe0 100644 --- a/src/plugins/bundled-plugin-metadata.test.ts +++ b/src/plugins/bundled-plugin-metadata.test.ts @@ -165,6 +165,17 @@ describe("bundled plugin metadata", () => { }); }); + it("keeps Slack's narrow runtime-setter sidecar on the bundled public surface", () => { + // Regression for #69317: the bundled channel entry now points its + // runtime.specifier at runtime-setter-api.js to avoid loading the full + // runtime-api barrel during register(). The setter file must therefore + // be discoverable as part of Slack's public surface. + const slack = listRepoBundledPluginMetadata().find((entry) => entry.dirName === "slack"); + expectArtifactPresence(slack?.publicSurfaceArtifacts, { + contains: ["runtime-setter-api.js"], + }); + }); + it("keeps Telegram's narrow runtime setter on the bundled runtime sidecar surface", () => { const telegram = listRepoBundledPluginMetadata().find((entry) => entry.dirName === "telegram"); expectArtifactPresence(telegram?.publicSurfaceArtifacts, { diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index 3eff5b98e16..63b1a73f679 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -239,6 +239,23 @@ describe("runtime api guardrails", () => { } }); + it("keeps Slack's narrow runtime-setter entrypoint pinned to a single export", () => { + // Regression for #69317. The bundled channel entry's runtime.specifier + // now points at runtime-setter-api.ts. The whole point of that file is + // to expose ONLY setSlackRuntime so that register() does not pay the + // cost of importing the full runtime-api barrel. If a future change + // re-broadens this file, this test fails so the perf regression is + // surfaced explicitly rather than silently re-introduced. + const setterFile = bundledPluginFile({ + rootDir: ROOT_DIR, + pluginId: "slack", + relativePath: "runtime-setter-api.ts", + }); + expect(readExportStatements(setterFile)).toEqual([ + 'export { setSlackRuntime } from "./src/runtime.js";', + ]); + }); + it("keeps Matrix's narrow runtime-setter entrypoint pinned to a single export", () => { const setterFile = bundledPluginFile({ rootDir: ROOT_DIR,