fix(outbound): restore generic delivery and security seams

This commit is contained in:
Peter Steinberger
2026-04-03 17:55:27 +01:00
parent ab96520bba
commit 856592cf00
57 changed files with 1930 additions and 1517 deletions

View File

@@ -1,7 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { listBundledPluginManifestSnapshots } from "./bundled-manifest-snapshots.js";
import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js";
export type BundledPluginContractSnapshot = {
pluginId: string;
@@ -15,27 +12,6 @@ export type BundledPluginContractSnapshot = {
toolNames: string[];
};
function resolveBundledManifestSnapshotDir(): string | undefined {
const packageRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
if (!packageRoot) {
return undefined;
}
for (const candidate of [
path.join(packageRoot, "extensions"),
path.join(packageRoot, "dist", "extensions"),
path.join(packageRoot, "dist-runtime", "extensions"),
]) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return undefined;
}
const BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS = listBundledPluginManifestSnapshots({
bundledDir: resolveBundledManifestSnapshotDir(),
});
function uniqueStrings(values: readonly string[] | undefined): string[] {
const result: string[] = [];
const seen = new Set<string>();
@@ -50,8 +26,13 @@ function uniqueStrings(values: readonly string[] | undefined): string[] {
return result;
}
const BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES = listBundledPluginMetadata({
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
});
export const BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS: readonly BundledPluginContractSnapshot[] =
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.map(({ manifest }) => ({
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.map(({ manifest }) => ({
pluginId: manifest.id,
cliBackendIds: uniqueStrings(manifest.cliBackends),
providerIds: uniqueStrings(manifest.providers),
@@ -130,7 +111,7 @@ export const BUNDLED_PROVIDER_PLUGIN_ID_ALIASES = Object.fromEntries(
) as Readonly<Record<string, string>>;
export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.flatMap(({ manifest }) =>
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.flatMap(({ manifest }) =>
(manifest.legacyPluginIds ?? []).map(
(legacyPluginId) => [legacyPluginId, manifest.id] as const,
),
@@ -138,7 +119,7 @@ export const BUNDLED_LEGACY_PLUGIN_ID_ALIASES = Object.fromEntries(
) as Readonly<Record<string, string>>;
export const BUNDLED_AUTO_ENABLE_PROVIDER_PLUGIN_IDS = Object.fromEntries(
BUNDLED_PLUGIN_MANIFEST_SNAPSHOTS.flatMap(({ manifest }) =>
BUNDLED_PLUGIN_METADATA_FOR_CAPABILITIES.flatMap(({ manifest }) =>
(manifest.autoEnableWhenConfiguredProviders ?? []).map((providerId) => [
providerId,
manifest.id,

View File

@@ -432,8 +432,6 @@ describe("plugin-sdk subpath exports", () => {
"resolveThreadBindingThreadName",
"resolveThreadBindingsEnabled",
"formatThreadBindingDisabledError",
"DISCORD_THREAD_BINDING_CHANNEL",
"MATRIX_THREAD_BINDING_CHANNEL",
"resolveControlCommandGate",
"resolveCommandAuthorizedFromAuthorizers",
"resolveDualTextControlCommandGate",
@@ -621,8 +619,6 @@ describe("plugin-sdk subpath exports", () => {
]);
expectSourceMentions("conversation-runtime", [
"DISCORD_THREAD_BINDING_CHANNEL",
"MATRIX_THREAD_BINDING_CHANNEL",
"formatThreadBindingDisabledError",
"resolveThreadBindingFarewellText",
"resolveThreadBindingConversationIdFromBindingId",

View File

@@ -1,39 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createDiscordTypingLease,
type CreateDiscordTypingLeaseParams,
} from "./runtime-discord-typing.js";
describe("createDiscordTypingLease", () => {
afterEach(() => {
vi.useRealTimers();
});
it("uses the Discord default interval and forwards pulse params", async () => {
vi.useFakeTimers();
const pulse: CreateDiscordTypingLeaseParams["pulse"] = vi.fn(async () => undefined);
const cfg = { channels: { discord: { token: "x" } } };
const lease = await createDiscordTypingLease({
channelId: "123",
accountId: "work",
cfg,
intervalMs: Number.NaN,
pulse,
});
expect(pulse).toHaveBeenCalledTimes(1);
expect(pulse).toHaveBeenCalledWith({
channelId: "123",
accountId: "work",
cfg,
});
await vi.advanceTimersByTimeAsync(7_999);
expect(pulse).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(pulse).toHaveBeenCalledTimes(2);
lease.stop();
});
});

View File

@@ -1,32 +0,0 @@
import { createTypingLease } from "./typing-lease.js";
export type CreateDiscordTypingLeaseParams = {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
intervalMs?: number;
pulse: (params: {
channelId: string;
accountId?: string;
cfg?: ReturnType<typeof import("../../config/config.js").loadConfig>;
}) => Promise<void>;
};
const DEFAULT_DISCORD_TYPING_INTERVAL_MS = 8_000;
export async function createDiscordTypingLease(params: CreateDiscordTypingLeaseParams): Promise<{
refresh: () => Promise<void>;
stop: () => void;
}> {
return await createTypingLease({
defaultIntervalMs: DEFAULT_DISCORD_TYPING_INTERVAL_MS,
errorLabel: "discord",
intervalMs: params.intervalMs,
pulse: params.pulse,
pulseArgs: {
channelId: params.channelId,
accountId: params.accountId,
cfg: params.cfg,
},
});
}

View File

@@ -53,6 +53,46 @@ export function resolvePluginRuntimeRecord(
};
}
export function resolvePluginRuntimeRecordByEntryBaseNames(
entryBaseNames: string[],
onMissing?: () => never,
): PluginRuntimeRecord | null {
const manifestRegistry = loadPluginManifestRegistry({
config: readPluginBoundaryConfigSafely(),
cache: true,
});
const matches = manifestRegistry.plugins.filter((plugin) => {
if (!plugin?.source) {
return false;
}
const record = {
rootDir: plugin.rootDir,
source: plugin.source,
};
return entryBaseNames.every(
(entryBaseName) => resolvePluginRuntimeModulePath(record, entryBaseName) !== null,
);
});
if (matches.length === 0) {
if (onMissing) {
onMissing();
}
return null;
}
if (matches.length > 1) {
const pluginIds = matches.map((plugin) => plugin.id).join(", ");
throw new Error(
`plugin runtime boundary is ambiguous for entries [${entryBaseNames.join(", ")}]: ${pluginIds}`,
);
}
const record = matches[0];
return {
...(record.origin ? { origin: record.origin } : {}),
rootDir: record.rootDir,
source: record.source,
};
}
export function resolvePluginRuntimeModulePath(
record: Pick<PluginRuntimeRecord, "rootDir" | "source">,
entryBaseName: string,

View File

@@ -1,26 +1,108 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { createJiti } from "jiti";
type WebChannelHeavyRuntimeModule = typeof import("@openclaw/whatsapp/runtime-api.js");
type WebChannelLightRuntimeModule = typeof import("@openclaw/whatsapp/light-runtime-api.js");
import type { ChannelAgentTool } from "../../channels/plugins/types.core.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
getDefaultLocalRoots as getDefaultLocalRootsImpl,
loadWebMedia as loadWebMediaImpl,
loadWebMediaRaw as loadWebMediaRawImpl,
optimizeImageToJpeg as optimizeImageToJpegImpl,
} from "../../media/web-media.js";
import type { PollInput } from "../../polls.js";
import {
loadPluginBoundaryModuleWithJiti,
resolvePluginRuntimeRecordByEntryBaseNames,
resolvePluginRuntimeModulePath,
resolvePluginRuntimeRecord,
} from "./runtime-plugin-boundary.js";
const WEB_CHANNEL_PLUGIN_ID = "whatsapp";
type WebChannelPluginRecord = {
origin: string;
origin?: string;
rootDir?: string;
source: string;
};
type WebChannelLightRuntimeModule = {
getActiveWebListener: (accountId?: string | null) => unknown;
getWebAuthAgeMs: (authDir?: string) => number | null;
logWebSelfId: (authDir?: string, runtime?: unknown, includeChannelPrefix?: boolean) => void;
logoutWeb: (params: {
authDir?: string;
isLegacyAuthDir?: boolean;
runtime?: unknown;
}) => Promise<boolean>;
readWebSelfId: (authDir?: string) => {
e164: string | null;
jid: string | null;
lid: string | null;
};
webAuthExists: (authDir?: string) => Promise<boolean>;
createWhatsAppLoginTool: () => ChannelAgentTool;
formatError: (error: unknown) => string;
getStatusCode: (error: unknown) => number | undefined;
pickWebChannel: (pref: string, authDir?: string) => Promise<string>;
WA_WEB_AUTH_DIR: string;
};
type WebChannelHeavyRuntimeModule = {
loginWeb: (
verbose: boolean,
waitForConnection?: (sock: unknown) => Promise<void>,
runtime?: unknown,
accountId?: string,
) => Promise<void>;
sendMessageWhatsApp: (
to: string,
body: string,
options: {
verbose: boolean;
cfg?: OpenClawConfig;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gifPlayback?: boolean;
accountId?: string;
},
) => Promise<{ messageId: string; toJid: string }>;
sendPollWhatsApp: (
to: string,
poll: PollInput,
options: { verbose: boolean; accountId?: string; cfg?: OpenClawConfig },
) => Promise<{ messageId: string; toJid: string }>;
sendReactionWhatsApp: (
chatJid: string,
messageId: string,
emoji: string,
options: {
verbose: boolean;
fromMe?: boolean;
participant?: string;
accountId?: string;
},
) => Promise<void>;
createWaSocket: (
printQr: boolean,
verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => Promise<unknown>;
handleWhatsAppAction: (
params: Record<string, unknown>,
cfg: OpenClawConfig,
) => Promise<AgentToolResult<unknown>>;
monitorWebChannel: (...args: unknown[]) => Promise<unknown>;
monitorWebInbox: (...args: unknown[]) => Promise<unknown>;
runWebHeartbeatOnce: (...args: unknown[]) => Promise<unknown>;
startWebLoginWithQr: (...args: unknown[]) => Promise<unknown>;
waitForWaConnection: (sock: unknown) => Promise<void>;
waitForWebLogin: (...args: unknown[]) => Promise<unknown>;
extractMediaPlaceholder: (...args: unknown[]) => unknown;
extractText: (...args: unknown[]) => unknown;
resolveHeartbeatRecipients: (...args: unknown[]) => unknown;
};
let cachedHeavyModulePath: string | null = null;
let cachedHeavyModule: WebChannelHeavyRuntimeModule | null = null;
let cachedLightModulePath: string | null = null;
@@ -29,9 +111,9 @@ let cachedLightModule: WebChannelLightRuntimeModule | null = null;
const jitiLoaders = new Map<boolean, ReturnType<typeof createJiti>>();
function resolveWebChannelPluginRecord(): WebChannelPluginRecord {
return resolvePluginRuntimeRecord(WEB_CHANNEL_PLUGIN_ID, () => {
return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => {
throw new Error(
`web channel plugin runtime is unavailable: missing plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
"web channel plugin runtime is unavailable: missing plugin that provides light-runtime-api and runtime-api",
);
}) as WebChannelPluginRecord;
}
@@ -41,14 +123,10 @@ function resolveWebChannelRuntimeModulePath(
entryBaseName: "light-runtime-api" | "runtime-api",
): string {
const modulePath = resolvePluginRuntimeModulePath(record, entryBaseName, () => {
throw new Error(
`web channel plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
);
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
});
if (!modulePath) {
throw new Error(
`web channel plugin runtime is unavailable: missing ${entryBaseName} for plugin '${WEB_CHANNEL_PLUGIN_ID}'`,
);
throw new Error(`web channel plugin runtime is unavailable: missing ${entryBaseName}`);
}
return modulePath;
}