mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(runtime): narrow bundled runtime startup surfaces
This commit is contained in:
@@ -1,3 +1,33 @@
|
||||
import { loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
type ChannelPluginModule = typeof import("./channel-plugin-runtime.js");
|
||||
|
||||
function createLazyObjectValue<T extends object>(load: () => T): T {
|
||||
return new Proxy({} as T, {
|
||||
get(_target, property, receiver) {
|
||||
return Reflect.get(load(), property, receiver);
|
||||
},
|
||||
has(_target, property) {
|
||||
return property in load();
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(load());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(load(), property);
|
||||
return descriptor ? { ...descriptor, configurable: true } : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function loadChannelPluginModule(): ChannelPluginModule {
|
||||
return loadBundledEntryExportSync<ChannelPluginModule>(import.meta.url, {
|
||||
specifier: "./channel-plugin-runtime.js",
|
||||
});
|
||||
}
|
||||
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag the broader Mattermost helper surfaces into lightweight plugin loads.
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
export const mattermostPlugin: ChannelPluginModule["mattermostPlugin"] = createLazyObjectValue(
|
||||
() => loadChannelPluginModule().mattermostPlugin as object,
|
||||
) as ChannelPluginModule["mattermostPlugin"];
|
||||
|
||||
4
extensions/mattermost/channel-plugin-runtime.ts
Normal file
4
extensions/mattermost/channel-plugin-runtime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Private runtime-bearing plugin export for the bundled Mattermost entry.
|
||||
// Keep the actual channel plugin value off the lighter channel-plugin-api seam
|
||||
// so bootstrap can lazy-load it without tripping bundle init cycles.
|
||||
export { mattermostPlugin } from "./src/channel.js";
|
||||
9
extensions/mattermost/src/channel.runtime.ts
Normal file
9
extensions/mattermost/src/channel.runtime.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
listMattermostDirectoryGroups,
|
||||
listMattermostDirectoryPeers,
|
||||
} from "./mattermost/directory.js";
|
||||
export { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
export { probeMattermost } from "./mattermost/probe.js";
|
||||
export { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||
export { sendMessageMattermost } from "./mattermost/send.js";
|
||||
export { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
|
||||
@@ -16,6 +16,7 @@ import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel
|
||||
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
@@ -40,24 +41,43 @@ import {
|
||||
resolveMattermostReplyToMode,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import {
|
||||
listMattermostDirectoryGroups,
|
||||
listMattermostDirectoryPeers,
|
||||
} from "./mattermost/directory.js";
|
||||
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||
import { probeMattermost } from "./mattermost/probe.js";
|
||||
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
|
||||
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||
import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js";
|
||||
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
|
||||
import type { MattermostSlashCommandConfig } from "./mattermost/slash-commands.js";
|
||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||
import { getMattermostRuntime } from "./runtime.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
|
||||
import { mattermostSetupAdapter } from "./setup-core.js";
|
||||
import { mattermostSetupWizard } from "./setup-surface.js";
|
||||
import type { MattermostConfig } from "./types.js";
|
||||
|
||||
const loadMattermostChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
|
||||
const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||
|
||||
function collectMattermostSlashCallbackPaths(
|
||||
raw?: Partial<MattermostSlashCommandConfig>,
|
||||
): 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<string>([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];
|
||||
}
|
||||
|
||||
const mattermostSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedMattermostAccount>({
|
||||
channelKey: "mattermost",
|
||||
resolveDmPolicy: (account) => account.config.dmPolicy,
|
||||
@@ -135,7 +155,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
const { postId, emojiName, remove } = parseMattermostReactActionParams(params);
|
||||
if (remove) {
|
||||
const result = await removeMattermostReaction({
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).removeMattermostReaction({
|
||||
cfg,
|
||||
postId,
|
||||
emojiName,
|
||||
@@ -152,7 +174,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
};
|
||||
}
|
||||
|
||||
const result = await addMattermostReaction({
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).addMattermostReaction({
|
||||
cfg,
|
||||
postId,
|
||||
emojiName,
|
||||
@@ -192,7 +216,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
const mediaUrl =
|
||||
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
|
||||
|
||||
const result = await sendMessageMattermost(to, message, {
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, message, {
|
||||
accountId: resolvedAccountId,
|
||||
replyToId,
|
||||
buttons: Array.isArray(params.buttons) ? params.buttons : undefined,
|
||||
@@ -342,10 +368,14 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listGroups: async (params) => listMattermostDirectoryGroups(params),
|
||||
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
|
||||
listPeers: async (params) => listMattermostDirectoryPeers(params),
|
||||
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
|
||||
listGroups: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params),
|
||||
listGroupsLive: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params),
|
||||
listPeers: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params),
|
||||
listPeersLive: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params),
|
||||
}),
|
||||
messaging: {
|
||||
defaultMarkdownTableMode: "off",
|
||||
@@ -355,7 +385,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
looksLikeId: looksLikeMattermostTargetId,
|
||||
hint: "<channelId|user:ID|channel:ID>",
|
||||
resolveTarget: async ({ cfg, accountId, input }) => {
|
||||
const resolved = await resolveMattermostOpaqueTarget({
|
||||
const resolved = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
cfg,
|
||||
accountId,
|
||||
@@ -389,12 +421,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
if (!token || !baseUrl) {
|
||||
return { ok: false, error: "bot token or baseUrl missing" };
|
||||
}
|
||||
return await probeMattermost(
|
||||
baseUrl,
|
||||
token,
|
||||
timeoutMs,
|
||||
isPrivateNetworkOptInEnabled(account.config),
|
||||
);
|
||||
return await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).probeMattermost(baseUrl, token, timeoutMs, isPrivateNetworkOptInEnabled(account.config));
|
||||
},
|
||||
resolveAccountSnapshot: ({ account, runtime }) => ({
|
||||
accountId: account.accountId,
|
||||
@@ -450,7 +479,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
botTokenSource: account.botTokenSource,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting channel`);
|
||||
return monitorMattermostProvider({
|
||||
return (await loadMattermostChannelRuntime()).monitorMattermostProvider({
|
||||
botToken: account.botToken ?? undefined,
|
||||
baseUrl: account.baseUrl ?? undefined,
|
||||
accountId: account.accountId,
|
||||
@@ -511,7 +540,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
attachedResults: {
|
||||
channel: "mattermost",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
|
||||
await sendMessageMattermost(to, text, {
|
||||
await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
@@ -526,7 +557,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
replyToId,
|
||||
threadId,
|
||||
}) =>
|
||||
await sendMessageMattermost(to, text, {
|
||||
await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const zaloRuntimeImportEnv = {
|
||||
HOME: process.env.HOME,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
PATH: process.env.PATH,
|
||||
TERM: process.env.TERM,
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/plugins/jiti-runtime-api.js";
|
||||
|
||||
describe("zalo runtime api", () => {
|
||||
it("exports the channel plugin without reentering setup surfaces", async () => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"-e",
|
||||
'const runtimeApi = await import("./extensions/zalo/runtime-api.ts"); process.stdout.write(runtimeApi.zaloPlugin.id);',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: zaloRuntimeImportEnv,
|
||||
timeout: 40_000,
|
||||
},
|
||||
);
|
||||
it("loads the narrow runtime api without reentering setup surfaces", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "zalo", "runtime-api.ts");
|
||||
|
||||
expect(stdout).toBe("zalo");
|
||||
}, 45_000);
|
||||
expect(
|
||||
loadRuntimeApiExportTypesViaJiti({
|
||||
modulePath: runtimeApiPath,
|
||||
exportNames: ["setZaloRuntime"],
|
||||
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/runtime-store"],
|
||||
}),
|
||||
).toEqual({
|
||||
setZaloRuntime: "function",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Private runtime barrel for the bundled Zalo extension.
|
||||
// Keep this barrel thin and free of local plugin self-imports so the bundled
|
||||
// entry loader can resolve the channel plugin without re-entering this module.
|
||||
export { zaloPlugin } from "./src/channel.js";
|
||||
// Keep this barrel thin and free of channel plugin exports so direct runtime
|
||||
// imports do not re-enter the full channel/setup surface.
|
||||
export * from "./src/runtime-api.js";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { iterateBootstrapChannelPlugins } from "../channels/plugins/bootstrap-registry.js";
|
||||
import { loadBundledPluginPublicSurfaceModuleSync } from "../plugin-sdk/facade-runtime.js";
|
||||
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
|
||||
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
|
||||
|
||||
const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret
|
||||
@@ -6,7 +8,38 @@ const SIBLING_REF_SHAPE = "sibling_ref"; // pragma: allowlist secret
|
||||
|
||||
function listChannelSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] {
|
||||
const entries: SecretTargetRegistryEntry[] = [];
|
||||
const handledChannelIds = new Set<string>();
|
||||
|
||||
for (const metadata of listBundledPluginMetadata({
|
||||
includeChannelConfigs: false,
|
||||
includeSyntheticChannelConfigs: false,
|
||||
})) {
|
||||
const channelIds = metadata.manifest.channels ?? [];
|
||||
if (channelIds.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!metadata.publicSurfaceArtifacts?.includes("contract-api.js")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const contractApi = loadBundledPluginPublicSurfaceModuleSync<{
|
||||
secretTargetRegistryEntries?: readonly SecretTargetRegistryEntry[];
|
||||
}>({
|
||||
dirName: metadata.dirName,
|
||||
artifactBasename: "contract-api.js",
|
||||
});
|
||||
entries.push(...(contractApi.secretTargetRegistryEntries ?? []));
|
||||
channelIds.forEach((channelId) => handledChannelIds.add(channelId));
|
||||
} catch {
|
||||
// Fall back to the full bootstrap plugin surface for channels that do not
|
||||
// expose a usable secret contract artifact.
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of iterateBootstrapChannelPlugins()) {
|
||||
if (handledChannelIds.has(plugin.id)) {
|
||||
continue;
|
||||
}
|
||||
entries.push(...(plugin.secrets?.secretTargetRegistryEntries ?? []));
|
||||
}
|
||||
return entries;
|
||||
|
||||
Reference in New Issue
Block a user