refactor: move bundled extension deps to plugin packages

This commit is contained in:
Peter Steinberger 2026-03-19 00:04:50 +00:00
parent 07d9f725b6
commit d7018aaf19
35 changed files with 284 additions and 369 deletions

View File

@ -181,13 +181,20 @@ OpenClaw scans, in order:
4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off)
- `<openclaw>/extensions/*`
- `<openclaw>/dist/extensions/*` in packaged installs
- `<workspace>/dist-runtime/extensions/*` in local built checkouts
- `<workspace>/extensions/*` in source/Vitest workflows
Many bundled provider plugins are enabled by default so model catalogs/runtime
hooks stay available without extra setup. Others still require explicit
enablement via `plugins.entries.<id>.enabled` or
`openclaw plugins enable <id>`.
Bundled plugin runtime dependencies are owned by each plugin package. Packaged
builds stage opted-in bundled dependencies under
`dist/extensions/<id>/node_modules` instead of requiring mirrored copies in the
root package.
Installed plugins are enabled by default, but can be disabled the same way.
Workspace plugins are **disabled by default** unless you explicitly enable them

View File

@ -3,6 +3,12 @@
"version": "2026.3.14",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"dependencies": {
"@buape/carbon": "0.0.0-beta-20260216184201",
"@discordjs/voice": "^0.19.2",
"discord-api-types": "^0.38.42",
"opusscript": "^0.1.1"
},
"openclaw": {
"extensions": [
"./index.ts"
@ -18,6 +24,14 @@
"blurb": "very well supported right now.",
"systemImage": "bubble.left.and.bubble.right"
},
"install": {
"npmSpec": "@openclaw/discord",
"localPath": "extensions/discord",
"defaultChoice": "npm"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToNpm": true
}

View File

@ -1,13 +1,14 @@
import { RequestClient } from "@buape/carbon";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime";
import type { RetryConfig } from "openclaw/plugin-sdk/infra-runtime";
import type { RetryRunner } from "openclaw/plugin-sdk/infra-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
mergeDiscordAccountConfig,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { createDiscordRetryRunner } from "./retry.js";
import { normalizeDiscordToken } from "./token.js";
export type DiscordClientOpts = {

View File

@ -33,7 +33,10 @@ import {
} from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import {
dispatchPluginInteractiveHandler,
type PluginInteractiveDiscordHandlerContext,
} from "openclaw/plugin-sdk/plugin-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import {
formatInboundEnvelope,
@ -117,7 +120,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
? `channel:${params.interactionCtx.channelId}`
: `user:${params.interactionCtx.userId}`;
let responded = false;
const respond = {
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
acknowledge: async () => {
responded = true;
await params.interaction.acknowledge();
@ -136,20 +139,15 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
ephemeral,
});
},
editMessage: async ({
text,
components,
}: {
text?: string;
components?: TopLevelComponents[];
}) => {
editMessage: async (input) => {
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
throw new Error("Discord interaction cannot update the source message");
}
const { text, components } = input;
responded = true;
await params.interaction.update({
...(text !== undefined ? { content: text } : {}),
...(components !== undefined ? { components } : {}),
...(components !== undefined ? { components: components as TopLevelComponents[] } : {}),
});
},
clearComponents: async (input?: { text?: string }) => {

View File

@ -2,11 +2,11 @@ import type { RequestClient } from "@buape/carbon";
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
import { createDiscordRetryRunner, type RetryRunner } from "openclaw/plugin-sdk/infra-runtime";
import {
resolveRetryConfig,
retryAsync,
type RetryConfig,
type RetryRunner,
} from "openclaw/plugin-sdk/infra-runtime";
import {
resolveSendableOutboundReplyParts,
@ -19,6 +19,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
import { resolveDiscordAccount } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { createDiscordRetryRunner } from "../retry.js";
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
import { sendDiscordText } from "../send.shared.js";

View File

@ -0,0 +1,27 @@
import { RateLimitError } from "@buape/carbon";
import {
createRateLimitRetryRunner,
type RetryConfig,
type RetryRunner,
} from "openclaw/plugin-sdk/infra-runtime";
export const DISCORD_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30_000,
jitter: 0.1,
} satisfies RetryConfig;
export function createDiscordRetryRunner(params: {
retry?: RetryConfig;
configRetry?: RetryConfig;
verbose?: boolean;
}): RetryRunner {
return createRateLimitRetryRunner({
...params,
defaults: DISCORD_RETRY_DEFAULTS,
logLabel: "discord",
shouldRetry: (err) => err instanceof RateLimitError,
retryAfterMs: (err) => (err instanceof RateLimitError ? err.retryAfter * 1000 : undefined),
});
}

View File

@ -5,17 +5,6 @@ import path from "node:path";
import type { Readable } from "node:stream";
import { ChannelType, type Client, ReadyListener } from "@buape/carbon";
import type { VoicePlugin } from "@buape/carbon/voice";
import {
AudioPlayerStatus,
EndBehaviorType,
VoiceConnectionStatus,
createAudioPlayer,
createAudioResource,
entersState,
joinVoiceChannel,
type AudioPlayer,
type VoiceConnection,
} from "@discordjs/voice";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
import { resolveTtsConfig, type ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime";
@ -34,6 +23,7 @@ import { textToSpeech } from "openclaw/plugin-sdk/speech-runtime";
import { formatMention } from "../mentions.js";
import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js";
import { formatDiscordUserTag } from "../monitor/format.js";
import { loadDiscordVoiceSdk } from "./sdk-runtime.js";
const require = createRequire(import.meta.url);
@ -67,8 +57,8 @@ type VoiceSessionEntry = {
channelId: string;
sessionChannelId: string;
route: ReturnType<typeof resolveAgentRoute>;
connection: VoiceConnection;
player: AudioPlayer;
connection: import("@discordjs/voice").VoiceConnection;
player: import("@discordjs/voice").AudioPlayer;
playbackQueue: Promise<void>;
processingQueue: Promise<void>;
activeSpeakers: Set<string>;
@ -378,7 +368,8 @@ export class DiscordVoiceManager {
decryptionFailureTolerance ?? "default"
}`,
);
const connection = joinVoiceChannel({
const voiceSdk = loadDiscordVoiceSdk();
const connection = voiceSdk.joinVoiceChannel({
channelId,
guildId,
adapterCreator,
@ -389,7 +380,11 @@ export class DiscordVoiceManager {
});
try {
await entersState(connection, VoiceConnectionStatus.Ready, PLAYBACK_READY_TIMEOUT_MS);
await voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Ready,
PLAYBACK_READY_TIMEOUT_MS,
);
logVoiceVerbose(`join: connected to guild ${guildId} channel ${channelId}`);
} catch (err) {
connection.destroy();
@ -412,7 +407,7 @@ export class DiscordVoiceManager {
peer: { kind: "channel", id: sessionChannelId },
});
const player = createAudioPlayer();
const player = voiceSdk.createAudioPlayer();
connection.subscribe(player);
let speakingHandler: ((userId: string) => void) | undefined;
@ -444,10 +439,10 @@ export class DiscordVoiceManager {
connection.receiver.speaking.off("start", speakingHandler);
}
if (disconnectedHandler) {
connection.off(VoiceConnectionStatus.Disconnected, disconnectedHandler);
connection.off(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler);
}
if (destroyedHandler) {
connection.off(VoiceConnectionStatus.Destroyed, destroyedHandler);
connection.off(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler);
}
if (playerErrorHandler) {
player.off("error", playerErrorHandler);
@ -466,8 +461,8 @@ export class DiscordVoiceManager {
disconnectedHandler = async () => {
try {
await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Signalling, 5_000),
voiceSdk.entersState(connection, voiceSdk.VoiceConnectionStatus.Connecting, 5_000),
]);
} catch {
clearSessionIfCurrent();
@ -482,8 +477,8 @@ export class DiscordVoiceManager {
};
connection.receiver.speaking.on("start", speakingHandler);
connection.on(VoiceConnectionStatus.Disconnected, disconnectedHandler);
connection.on(VoiceConnectionStatus.Destroyed, destroyedHandler);
connection.on(voiceSdk.VoiceConnectionStatus.Disconnected, disconnectedHandler);
connection.on(voiceSdk.VoiceConnectionStatus.Destroyed, destroyedHandler);
player.on("error", playerErrorHandler);
this.sessions.set(guildId, entry);
@ -547,13 +542,14 @@ export class DiscordVoiceManager {
logVoiceVerbose(
`capture start: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`,
);
if (entry.player.state.status === AudioPlayerStatus.Playing) {
const voiceSdk = loadDiscordVoiceSdk();
if (entry.player.state.status === voiceSdk.AudioPlayerStatus.Playing) {
entry.player.stop(true);
}
const stream = entry.connection.receiver.subscribe(userId, {
end: {
behavior: EndBehaviorType.AfterSilence,
behavior: voiceSdk.EndBehaviorType.AfterSilence,
duration: SILENCE_DURATION_MS,
},
});
@ -681,14 +677,15 @@ export class DiscordVoiceManager {
logVoiceVerbose(
`playback start: guild ${entry.guildId} channel ${entry.channelId} file ${path.basename(audioPath)}`,
);
const resource = createAudioResource(audioPath);
const voiceSdk = loadDiscordVoiceSdk();
const resource = voiceSdk.createAudioResource(audioPath);
entry.player.play(resource);
await entersState(entry.player, AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS).catch(
() => undefined,
);
await entersState(entry.player, AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS).catch(
() => undefined,
);
await voiceSdk
.entersState(entry.player, voiceSdk.AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS)
.catch(() => undefined);
await voiceSdk
.entersState(entry.player, voiceSdk.AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS)
.catch(() => undefined);
logVoiceVerbose(`playback done: guild ${entry.guildId} channel ${entry.channelId}`);
});
}

View File

@ -0,0 +1,14 @@
import { createRequire } from "node:module";
type DiscordVoiceSdk = typeof import("@discordjs/voice");
let cachedDiscordVoiceSdk: DiscordVoiceSdk | null = null;
export function loadDiscordVoiceSdk(): DiscordVoiceSdk {
if (cachedDiscordVoiceSdk) {
return cachedDiscordVoiceSdk;
}
const req = createRequire(import.meta.url);
cachedDiscordVoiceSdk = req("@discordjs/voice") as DiscordVoiceSdk;
return cachedDiscordVoiceSdk;
}

View File

@ -32,6 +32,9 @@
"localPath": "extensions/feishu",
"defaultChoice": "npm"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToNpm": true
}

View File

@ -38,11 +38,6 @@
"npmSpec": "@openclaw/googlechat",
"localPath": "extensions/googlechat",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"google-auth-library"
]
}
}
}

View File

@ -33,13 +33,6 @@
},
"release": {
"publishToNpm": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@matrix-org/matrix-sdk-crypto-nodejs",
"@vector-im/matrix-bot-sdk",
"music-metadata"
]
}
}
}

View File

@ -32,11 +32,6 @@
},
"release": {
"publishToNpm": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@microsoft/agents-hosting"
]
}
}
}

View File

@ -29,11 +29,6 @@
},
"release": {
"publishToNpm": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"nostr-tools"
]
}
}
}

View File

@ -28,13 +28,6 @@
"npmSpec": "@openclaw/tlon",
"localPath": "extensions/tlon",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@tloncorp/api",
"@tloncorp/tlon-skill",
"@urbit/aura"
]
}
}
}

View File

@ -33,11 +33,6 @@
},
"release": {
"publishToNpm": true
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"zca-js"
]
}
}
}

View File

@ -476,10 +476,11 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts",
"release:check": "pnpm config:docs:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts",
"release:plugins:npm:plan": "node --import tsx scripts/plugin-npm-release-plan.ts",
"stage:bundled-plugin-runtime-deps": "node scripts/stage-bundled-plugin-runtime-deps.mjs",
"start": "node scripts/run-node.mjs",
"test": "node scripts/test-parallel.mjs",
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
@ -534,14 +535,11 @@
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"@aws-sdk/client-bedrock": "^3.1011.0",
"@buape/carbon": "0.0.0-beta-20260216184201",
"@clack/prompts": "^1.1.0",
"@discordjs/voice": "^0.19.2",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.5",
"@lancedb/lancedb": "^0.27.0",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.58.0",
@ -560,7 +558,6 @@
"cli-highlight": "^2.1.11",
"commander": "^14.0.3",
"croner": "^10.0.1",
"discord-api-types": "^0.38.42",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"file-type": "21.3.3",
@ -576,7 +573,6 @@
"long": "^5.3.2",
"markdown-it": "^14.1.1",
"node-edge-tts": "^1.2.10",
"opusscript": "^0.1.1",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.5.207",
"playwright-core": "1.58.2",

58
pnpm-lock.yaml generated
View File

@ -34,15 +34,9 @@ importers:
'@aws-sdk/client-bedrock':
specifier: ^3.1011.0
version: 3.1011.0
'@buape/carbon':
specifier: 0.0.0-beta-20260216184201
version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1)
'@clack/prompts':
specifier: ^1.1.0
version: 1.1.0
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@grammyjs/runner':
specifier: ^2.0.3
version: 2.0.3(grammy@1.41.1)
@ -55,9 +49,6 @@ importers:
'@lancedb/lancedb':
specifier: ^0.27.0
version: 0.27.0(apache-arrow@18.1.0)
'@larksuiteoapi/node-sdk':
specifier: ^1.59.0
version: 1.59.0
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0
@ -115,9 +106,6 @@ importers:
croner:
specifier: ^10.0.1
version: 10.0.1
discord-api-types:
specifier: ^0.38.42
version: 0.38.42
dotenv:
specifier: ^17.3.1
version: 17.3.1
@ -166,9 +154,6 @@ importers:
node-llama-cpp:
specifier: 3.16.2
version: 3.16.2(typescript@5.9.3)
opusscript:
specifier: ^0.1.1
version: 0.1.1
osc-progress:
specifier: ^0.3.0
version: 0.3.0
@ -347,7 +332,20 @@ importers:
specifier: 1.58.2
version: 1.58.2
extensions/discord: {}
extensions/discord:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260216184201
version: 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1)
'@discordjs/voice':
specifier: ^0.19.2
version: 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1)
discord-api-types:
specifier: ^0.38.42
version: 0.38.42
opusscript:
specifier: ^0.1.1
version: 0.1.1
extensions/elevenlabs: {}
@ -381,7 +379,7 @@ importers:
version: 10.6.2
openclaw:
specifier: '>=2026.3.11'
version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3))
version: 2026.3.13(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3))
extensions/huggingface: {}
@ -448,7 +446,7 @@ importers:
dependencies:
openclaw:
specifier: '>=2026.3.11'
version: 2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3))
version: 2026.3.13(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3))
extensions/memory-lancedb:
dependencies:
@ -1210,10 +1208,6 @@ packages:
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
'@discordjs/voice@0.19.1':
resolution: {integrity: sha512-XYbFVyUBB7zhRvrjREfiWDwio24nEp/vFaVe6u9aBIC5UYuT7HvoMt8LgNfZ5hOyaCW0flFr72pkhUGz+gWw4Q==}
engines: {node: '>=22.12.0'}
'@discordjs/voice@0.19.2':
resolution: {integrity: sha512-3yJ255e4ag3wfZu/DSxeOZK1UtnqNxnspmLaQetGT0pDkThNZoHs+Zg6dgZZ19JEVomXygvfHn9lNpICZuYtEA==}
engines: {node: '>=22.12.0'}
@ -8386,22 +8380,6 @@ snapshots:
- utf-8-validate
optional: true
'@discordjs/voice@0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1)':
dependencies:
'@snazzah/davey': 0.1.10
'@types/ws': 8.18.1
discord-api-types: 0.38.42
prism-media: 1.3.5(@discordjs/opus@0.10.0)(opusscript@0.1.1)
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
- '@discordjs/opus'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
'@discordjs/voice@0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1)':
dependencies:
'@snazzah/davey': 0.1.10
@ -13445,13 +13423,13 @@ snapshots:
ws: 8.19.0
zod: 4.3.6
openclaw@2026.3.13(@discordjs/opus@0.10.0)(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)):
openclaw@2026.3.13(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(audio-decode@2.2.3)(node-llama-cpp@3.16.2(typescript@5.9.3)):
dependencies:
'@agentclientprotocol/sdk': 0.16.1(zod@4.3.6)
'@aws-sdk/client-bedrock': 3.1009.0
'@buape/carbon': 0.0.0-beta-20260216184201(@discordjs/opus@0.10.0)(hono@4.12.8)(opusscript@0.1.1)
'@clack/prompts': 1.1.0
'@discordjs/voice': 0.19.1(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@discordjs/voice': 0.19.2(@discordjs/opus@0.10.0)(opusscript@0.1.1)
'@grammyjs/runner': 2.0.3(grammy@1.41.1)
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.41.1)
'@homebridge/ciao': 1.3.5

View File

@ -403,9 +403,6 @@ async function buildMissingPackages() {
continue;
}
const meta = packageClusterMeta(relativePackagePath);
const rootDependencyMirrorAllowlist = (
pkg.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? []
).toSorted(compareStrings);
const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted(
compareStrings,
);
@ -421,9 +418,6 @@ async function buildMissingPackages() {
packagePath: relativePackagePath,
npmSpec: pkg.openclaw?.install?.npmSpec ?? null,
private: pkg.private === true,
rootDependencyMirrorAllowlist,
mirrorAllowlistMatchesMissing:
missing.join("\n") === rootDependencyMirrorAllowlist.join("\n"),
pluginSdkReachability:
pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined,
missing,

View File

@ -7,33 +7,10 @@ export type ExtensionPackageJson = {
install?: {
npmSpec?: string;
};
releaseChecks?: {
rootDependencyMirrorAllowlist?: string[];
};
};
};
export type BundledExtension = { id: string; packageJson: ExtensionPackageJson };
export type BundledExtensionMetadata = BundledExtension & {
npmSpec?: string;
rootDependencyMirrorAllowlist: string[];
};
export function normalizeBundledExtensionMetadata(
extensions: BundledExtension[],
): BundledExtensionMetadata[] {
return extensions.map((extension) => ({
...extension,
npmSpec:
typeof extension.packageJson.openclaw?.install?.npmSpec === "string"
? extension.packageJson.openclaw.install.npmSpec.trim()
: undefined,
rootDependencyMirrorAllowlist:
extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
) ?? [],
}));
}
export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] {
const errors: string[] = [];
@ -48,23 +25,6 @@ export function collectBundledExtensionManifestErrors(extensions: BundledExtensi
`bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`,
);
}
const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
if (allowlist === undefined) {
continue;
}
if (!Array.isArray(allowlist)) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`,
);
continue;
}
const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim());
if (invalidEntries.length > 0) {
errors.push(
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`,
);
}
}
return errors;

View File

@ -6,7 +6,6 @@ import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import {
collectBundledExtensionManifestErrors,
normalizeBundledExtensionMetadata,
type BundledExtension,
type ExtensionPackageJson as PackageJson,
} from "./lib/bundled-extension-manifest.ts";
@ -34,45 +33,6 @@ const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
const laneFloorAdoptionDateKey = 20260227;
export function collectBundledExtensionRootDependencyGapErrors(params: {
rootPackage: PackageJson;
extensions: BundledExtension[];
}): string[] {
const rootDeps = {
...params.rootPackage.dependencies,
...params.rootPackage.optionalDependencies,
};
const errors: string[] = [];
for (const extension of normalizeBundledExtensionMetadata(params.extensions)) {
if (!extension.npmSpec) {
continue;
}
const missing = Object.keys(extension.packageJson.dependencies ?? {})
.filter((dep) => dep !== "openclaw" && !rootDeps[dep])
.toSorted();
const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted();
if (missing.join("\n") !== allowlisted.join("\n")) {
const unexpected = missing.filter((dep) => !allowlisted.includes(dep));
const resolved = allowlisted.filter((dep) => !missing.includes(dep));
const parts = [
`bundled extension '${extension.id}' root dependency mirror drift`,
`missing in root package: ${missing.length > 0 ? missing.join(", ") : "(none)"}`,
];
if (unexpected.length > 0) {
parts.push(`new gaps: ${unexpected.join(", ")}`);
}
if (resolved.length > 0) {
parts.push(`remove stale allowlist entries: ${resolved.join(", ")}`);
}
errors.push(parts.join(" | "));
}
}
return errors;
}
function collectBundledExtensions(): BundledExtension[] {
const extensionsDir = resolve("extensions");
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
@ -94,8 +54,7 @@ function collectBundledExtensions(): BundledExtension[] {
});
}
function checkBundledExtensionRootDependencyMirrors() {
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson;
function checkBundledExtensionMetadata() {
const extensions = collectBundledExtensions();
const manifestErrors = collectBundledExtensionManifestErrors(extensions);
if (manifestErrors.length > 0) {
@ -105,17 +64,6 @@ function checkBundledExtensionRootDependencyMirrors() {
}
process.exit(1);
}
const errors = collectBundledExtensionRootDependencyGapErrors({
rootPackage,
extensions,
});
if (errors.length > 0) {
console.error("release-check: bundled extension root dependency mirror validation failed:");
for (const error of errors) {
console.error(` - ${error}`);
}
process.exit(1);
}
}
function runPackDry(): PackResult[] {
@ -128,11 +76,13 @@ function runPackDry(): PackResult[] {
}
export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
const isAllowedBundledPluginNodeModulesPath = (path: string) =>
/^dist\/extensions\/[^/]+\/node_modules\//.test(path);
return [...paths]
.filter(
(path) =>
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)) ||
/(^|\/)node_modules\//.test(path),
(/node_modules\//.test(path) && !isAllowedBundledPluginNodeModulesPath(path)),
)
.toSorted();
}
@ -338,7 +288,7 @@ async function checkPluginSdkExports() {
async function main() {
checkAppcastSparkleVersions();
await checkPluginSdkExports();
checkBundledExtensionRootDependencyMirrors();
checkBundledExtensionMetadata();
const results = runPackDry();
const files = results.flatMap((entry) => entry.files ?? []);

View File

@ -1,11 +1,13 @@
import { pathToFileURL } from "node:url";
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
import { stageBundledPluginRuntimeDeps } from "./stage-bundled-plugin-runtime-deps.mjs";
import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs";
export function runRuntimePostBuild(params = {}) {
copyPluginSdkRootAlias(params);
copyBundledPluginMetadata(params);
stageBundledPluginRuntimeDeps(params);
stageBundledPluginRuntime(params);
}

View File

@ -0,0 +1,74 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function removePathIfExists(targetPath) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
function listBundledPluginRuntimeDirs(repoRoot) {
const extensionsRoot = path.join(repoRoot, "dist", "extensions");
if (!fs.existsSync(extensionsRoot)) {
return [];
}
return fs
.readdirSync(extensionsRoot, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => path.join(extensionsRoot, dirent.name))
.filter((pluginDir) => fs.existsSync(path.join(pluginDir, "package.json")));
}
function hasRuntimeDeps(packageJson) {
return (
Object.keys(packageJson.dependencies ?? {}).length > 0 ||
Object.keys(packageJson.optionalDependencies ?? {}).length > 0
);
}
function shouldStageRuntimeDeps(packageJson) {
return packageJson.openclaw?.bundle?.stageRuntimeDependencies === true;
}
function installPluginRuntimeDeps(pluginDir, pluginId) {
const result = spawnSync(
"npm",
["install", "--omit=dev", "--silent", "--ignore-scripts", "--package-lock=false"],
{
cwd: pluginDir,
encoding: "utf8",
stdio: "pipe",
shell: process.platform === "win32",
},
);
if (result.status === 0) {
return;
}
const output = [result.stderr, result.stdout].filter(Boolean).join("\n").trim();
throw new Error(
`failed to stage bundled runtime deps for ${pluginId}: ${output || "npm install failed"}`,
);
}
export function stageBundledPluginRuntimeDeps(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) {
const pluginId = path.basename(pluginDir);
const packageJson = readJson(path.join(pluginDir, "package.json"));
const nodeModulesDir = path.join(pluginDir, "node_modules");
removePathIfExists(nodeModulesDir);
if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) {
continue;
}
installPluginRuntimeDeps(pluginDir, pluginId);
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
stageBundledPluginRuntimeDeps();
}

View File

@ -98,7 +98,6 @@ export function stageBundledPluginRuntime(params = {}) {
const repoRoot = params.cwd ?? params.repoRoot ?? process.cwd();
const distRoot = path.join(repoRoot, "dist");
const runtimeRoot = path.join(repoRoot, "dist-runtime");
const sourceExtensionsRoot = path.join(repoRoot, "extensions");
const distExtensionsRoot = path.join(distRoot, "extensions");
const runtimeExtensionsRoot = path.join(runtimeRoot, "extensions");
@ -116,13 +115,12 @@ export function stageBundledPluginRuntime(params = {}) {
}
const distPluginDir = path.join(distExtensionsRoot, dirent.name);
const runtimePluginDir = path.join(runtimeExtensionsRoot, dirent.name);
const sourcePluginNodeModulesDir = path.join(sourceExtensionsRoot, dirent.name, "node_modules");
const distPluginNodeModulesDir = path.join(distPluginDir, "node_modules");
stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir);
linkPluginNodeModules({
runtimePluginDir,
distPluginDir,
sourcePluginNodeModulesDir,
sourcePluginNodeModulesDir: distPluginNodeModulesDir,
});
}
}

View File

@ -33,9 +33,9 @@ function removeDistPluginNodeModulesSymlinks(rootDir) {
function pruneStaleRuntimeSymlinks() {
const cwd = process.cwd();
// runtime-postbuild links dist/dist-runtime plugin node_modules back into the
// source extensions. Remove only those symlinks up front so tsdown's clean
// step cannot traverse into the active pnpm install tree on rebuilds.
// runtime-postbuild stages plugin-owned node_modules into dist/ and links the
// dist-runtime overlay back to that tree. Remove only those symlinks up front
// so tsdown's clean step cannot traverse stale runtime overlays on rebuilds.
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist"));
removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime"));
}

View File

@ -1,4 +1,3 @@
import type { TopLevelComponents } from "@buape/carbon";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import type { TSchema } from "@sinclair/typebox";
import type { MsgContext } from "../../auto-reply/templating.js";
@ -276,12 +275,16 @@ export type ChannelStreamingAdapter = {
};
};
// Keep core transport-agnostic. Plugins can carry richer component types on
// their side and cast at the boundary.
export type ChannelStructuredComponents = unknown[];
export type ChannelCrossContextComponentsFactory = (params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) => TopLevelComponents[];
}) => ChannelStructuredComponents;
export type ChannelReplyTransport = {
replyToId?: string | null;

View File

@ -70,6 +70,7 @@ export type {
ChannelSetupInput,
ChannelStatusIssue,
ChannelStreamingAdapter,
ChannelStructuredComponents,
ChannelThreadingAdapter,
ChannelThreadingContext,
ChannelThreadingToolContext,

View File

@ -1,16 +1,15 @@
import type { TopLevelComponents } from "@buape/carbon";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { ChannelId, ChannelStructuredComponents } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
export type CrossContextComponentsBuilder = (message: string) => TopLevelComponents[];
export type CrossContextComponentsBuilder = (message: string) => ChannelStructuredComponents;
export type CrossContextComponentsFactory = (params: {
originLabel: string;
message: string;
cfg: OpenClawConfig;
accountId?: string | null;
}) => TopLevelComponents[];
}) => ChannelStructuredComponents;
export type ChannelMessageAdapter = {
supportsComponentsV2: boolean;

View File

@ -1,17 +1,9 @@
import { RateLimitError } from "@buape/carbon";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { formatErrorMessage } from "./errors.js";
import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js";
export type RetryRunner = <T>(fn: () => Promise<T>, label?: string) => Promise<T>;
export const DISCORD_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30_000,
jitter: 0.1,
};
export const TELEGRAM_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 400,
@ -58,12 +50,16 @@ function getTelegramRetryAfterMs(err: unknown): number | undefined {
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate * 1000 : undefined;
}
export function createDiscordRetryRunner(params: {
export function createRateLimitRetryRunner(params: {
retry?: RetryConfig;
configRetry?: RetryConfig;
verbose?: boolean;
defaults: Required<RetryConfig>;
logLabel: string;
shouldRetry: (err: unknown) => boolean;
retryAfterMs?: (err: unknown) => number | undefined;
}): RetryRunner {
const retryConfig = resolveRetryConfig(DISCORD_RETRY_DEFAULTS, {
const retryConfig = resolveRetryConfig(params.defaults, {
...params.configRetry,
...params.retry,
});
@ -71,14 +67,14 @@ export function createDiscordRetryRunner(params: {
retryAsync(fn, {
...retryConfig,
label,
shouldRetry: (err) => err instanceof RateLimitError,
retryAfterMs: (err) => (err instanceof RateLimitError ? err.retryAfter * 1000 : undefined),
shouldRetry: params.shouldRetry,
retryAfterMs: params.retryAfterMs,
onRetry: params.verbose
? (info) => {
const labelText = info.label ?? "request";
const maxRetries = Math.max(1, info.maxAttempts - 1);
log.warn(
`discord ${labelText} rate limited, retry ${info.attempt}/${maxRetries} in ${info.delayMs}ms`,
`${params.logLabel} ${labelText} rate limited, retry ${info.attempt}/${maxRetries} in ${info.delayMs}ms`,
);
}
: undefined,

View File

@ -14,6 +14,7 @@ export * from "../media/outbound-attachment.js";
export * from "../media/png-encode.ts";
export * from "../media/store.js";
export * from "../media/temp-files.js";
export * from "./agent-media-payload.js";
export * from "../media-understanding/audio-preflight.ts";
export * from "../media-understanding/defaults.js";
export * from "../media-understanding/providers/image-runtime.ts";

View File

@ -50,6 +50,22 @@ describe("resolveBundledPluginsDir", () => {
);
});
it("falls back to built dist/extensions in installed package roots", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-dist-");
fs.mkdirSync(path.join(repoRoot, "dist", "extensions"), { recursive: true });
fs.writeFileSync(
path.join(repoRoot, "package.json"),
`${JSON.stringify({ name: "openclaw" }, null, 2)}\n`,
"utf8",
);
process.chdir(repoRoot);
expect(fs.realpathSync(resolveBundledPluginsDir() ?? "")).toBe(
fs.realpathSync(path.join(repoRoot, "dist", "extensions")),
);
});
it("prefers source extensions under vitest to avoid stale staged plugins", () => {
const repoRoot = makeRepoRoot("openclaw-bundled-dir-vitest-");
fs.mkdirSync(path.join(repoRoot, "extensions"), { recursive: true });

View File

@ -29,6 +29,7 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
);
for (const packageRoot of packageRoots) {
const sourceExtensionsDir = path.join(packageRoot, "extensions");
const builtExtensionsDir = path.join(packageRoot, "dist", "extensions");
if (
(preferSourceCheckout || isSourceCheckoutRoot(packageRoot)) &&
fs.existsSync(sourceExtensionsDir)
@ -39,10 +40,12 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
// dist-runtime/. Prefer that over source extensions only when the paired
// dist/ tree exists; otherwise wrappers can drift ahead of the last build.
const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions");
const builtExtensionsDir = path.join(packageRoot, "dist", "extensions");
if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) {
return runtimeExtensionsDir;
}
if (fs.existsSync(builtExtensionsDir)) {
return builtExtensionsDir;
}
}
} catch {
// ignore
@ -51,6 +54,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
// bun --compile: ship a sibling `extensions/` next to the executable.
try {
const execDir = path.dirname(process.execPath);
const siblingBuilt = path.join(execDir, "dist", "extensions");
if (fs.existsSync(siblingBuilt)) {
return siblingBuilt;
}
const sibling = path.join(execDir, "extensions");
if (fs.existsSync(sibling)) {
return sibling;

View File

@ -12,25 +12,33 @@ function readJson<T>(relativePath: string): T {
}
describe("bundled plugin runtime dependencies", () => {
it("keeps bundled Feishu runtime deps available from the published root package", () => {
it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => {
const rootManifest = readJson<PackageManifest>("package.json");
const feishuManifest = readJson<PackageManifest>("extensions/feishu/package.json");
const feishuSpec = feishuManifest.dependencies?.["@larksuiteoapi/node-sdk"];
const rootSpec = rootManifest.dependencies?.["@larksuiteoapi/node-sdk"];
expect(feishuSpec).toBeTruthy();
expect(rootSpec).toBeTruthy();
expect(rootSpec).toBe(feishuSpec);
expect(rootSpec).toBeUndefined();
});
it("keeps bundled memory-lancedb runtime deps available from the published root package", () => {
it("keeps bundled memory-lancedb runtime deps available from the root package while its native runtime stays bundled", () => {
const rootManifest = readJson<PackageManifest>("package.json");
const memoryManifest = readJson<PackageManifest>("extensions/memory-lancedb/package.json");
const memorySpec = memoryManifest.dependencies?.["@lancedb/lancedb"];
const rootSpec = rootManifest.dependencies?.["@lancedb/lancedb"];
expect(memorySpec).toBeTruthy();
expect(rootSpec).toBeTruthy();
expect(rootSpec).toBe(memorySpec);
});
it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => {
const rootManifest = readJson<PackageManifest>("package.json");
const discordManifest = readJson<PackageManifest>("extensions/discord/package.json");
const discordSpec = discordManifest.dependencies?.["@buape/carbon"];
const rootSpec = rootManifest.dependencies?.["@buape/carbon"];
expect(discordSpec).toBeTruthy();
expect(rootSpec).toBeUndefined();
});
});

View File

@ -22,18 +22,17 @@ afterEach(() => {
});
describe("stageBundledPluginRuntime", () => {
it("stages bundled dist plugins as runtime wrappers and links plugin-local node_modules", () => {
it("stages bundled dist plugins as runtime wrappers and links staged dist node_modules", () => {
const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-");
const distPluginDir = path.join(repoRoot, "dist", "extensions", "diffs");
fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
const sourcePluginNodeModulesDir = path.join(repoRoot, "extensions", "diffs", "node_modules");
fs.mkdirSync(distPluginDir, { recursive: true });
fs.mkdirSync(path.join(sourcePluginNodeModulesDir, "@pierre", "diffs"), {
fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), {
recursive: true,
});
fs.writeFileSync(path.join(distPluginDir, "index.js"), "export default {}\n", "utf8");
fs.writeFileSync(
path.join(sourcePluginNodeModulesDir, "@pierre", "diffs", "index.js"),
path.join(distPluginDir, "node_modules", "@pierre", "diffs", "index.js"),
"export default {}\n",
"utf8",
);
@ -47,9 +46,9 @@ describe("stageBundledPluginRuntime", () => {
);
expect(fs.lstatSync(path.join(runtimePluginDir, "node_modules")).isSymbolicLink()).toBe(true);
expect(fs.realpathSync(path.join(runtimePluginDir, "node_modules"))).toBe(
fs.realpathSync(sourcePluginNodeModulesDir),
fs.realpathSync(path.join(distPluginDir, "node_modules")),
);
expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(false);
expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(true);
});
it("writes wrappers that forward plugin entry imports into canonical dist files", async () => {

View File

@ -1,5 +1,4 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { TopLevelComponents } from "@buape/carbon";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
@ -16,7 +15,11 @@ import type { ProviderCapabilities } from "../agents/provider-capabilities.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js";
import type {
ChannelId,
ChannelPlugin,
ChannelStructuredComponents,
} from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
@ -1132,7 +1135,10 @@ export type PluginInteractiveDiscordHandlerContext = {
acknowledge: () => Promise<void>;
reply: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
followUp: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
editMessage: (params: { text?: string; components?: TopLevelComponents[] }) => Promise<void>;
editMessage: (params: {
text?: string;
components?: ChannelStructuredComponents;
}) => Promise<void>;
clearComponents: (params?: { text?: string }) => Promise<void>;
};
requestConversationBinding: (

View File

@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
collectAppcastSparkleVersionErrors,
collectBundledExtensionManifestErrors,
collectBundledExtensionRootDependencyGapErrors,
collectForbiddenPackPaths,
collectPackUnpackedSizeErrors,
} from "../scripts/release-check.ts";
@ -37,87 +36,6 @@ describe("collectAppcastSparkleVersionErrors", () => {
});
});
describe("collectBundledExtensionRootDependencyGapErrors", () => {
it("allows known gaps but still flags unallowlisted ones", () => {
expect(
collectBundledExtensionRootDependencyGapErrors({
rootPackage: { dependencies: {} },
extensions: [
{
id: "googlechat",
packageJson: {
dependencies: { "google-auth-library": "^1.0.0" },
openclaw: {
install: { npmSpec: "@openclaw/googlechat" },
releaseChecks: {
rootDependencyMirrorAllowlist: ["google-auth-library"],
},
},
},
},
{
id: "feishu",
packageJson: {
dependencies: { "@larksuiteoapi/node-sdk": "^1.59.0" },
openclaw: { install: { npmSpec: "@openclaw/feishu" } },
},
},
],
}),
).toEqual([
"bundled extension 'feishu' root dependency mirror drift | missing in root package: @larksuiteoapi/node-sdk | new gaps: @larksuiteoapi/node-sdk",
]);
});
it("flags newly introduced bundled extension dependency gaps", () => {
expect(
collectBundledExtensionRootDependencyGapErrors({
rootPackage: { dependencies: {} },
extensions: [
{
id: "googlechat",
packageJson: {
dependencies: { "google-auth-library": "^1.0.0", undici: "^7.0.0" },
openclaw: {
install: { npmSpec: "@openclaw/googlechat" },
releaseChecks: {
rootDependencyMirrorAllowlist: ["google-auth-library"],
},
},
},
},
],
}),
).toEqual([
"bundled extension 'googlechat' root dependency mirror drift | missing in root package: google-auth-library, undici | new gaps: undici",
]);
});
it("flags stale allowlist entries once a gap is resolved", () => {
expect(
collectBundledExtensionRootDependencyGapErrors({
rootPackage: { dependencies: { "google-auth-library": "^1.0.0" } },
extensions: [
{
id: "googlechat",
packageJson: {
dependencies: { "google-auth-library": "^1.0.0" },
openclaw: {
install: { npmSpec: "@openclaw/googlechat" },
releaseChecks: {
rootDependencyMirrorAllowlist: ["google-auth-library"],
},
},
},
},
],
}),
).toEqual([
"bundled extension 'googlechat' root dependency mirror drift | missing in root package: (none) | remove stale allowlist entries: google-auth-library",
]);
});
});
describe("collectBundledExtensionManifestErrors", () => {
it("flags invalid bundled extension install metadata", () => {
expect(
@ -135,33 +53,14 @@ describe("collectBundledExtensionManifestErrors", () => {
"bundled extension 'broken' manifest invalid | openclaw.install.npmSpec must be a non-empty string",
]);
});
it("flags invalid release-check allowlist metadata", () => {
expect(
collectBundledExtensionManifestErrors([
{
id: "broken",
packageJson: {
openclaw: {
install: { npmSpec: "@openclaw/broken" },
releaseChecks: {
rootDependencyMirrorAllowlist: ["ok", ""],
},
},
},
},
]),
).toEqual([
"bundled extension 'broken' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings",
]);
});
});
describe("collectForbiddenPackPaths", () => {
it("flags nested node_modules leaking into npm pack output", () => {
it("allows bundled plugin runtime deps under dist/extensions but still blocks other node_modules", () => {
expect(
collectForbiddenPackPaths([
"dist/index.js",
"dist/extensions/discord/node_modules/@buape/carbon/index.js",
"extensions/tlon/node_modules/.bin/tlon",
"node_modules/.bin/openclaw",
]),