fix(channels): isolate bundled load failures

This commit is contained in:
Peter Steinberger
2026-04-22 23:43:45 +01:00
parent e8b56a9928
commit 9b1f1036ac
6 changed files with 403 additions and 28 deletions

View File

@@ -127,8 +127,15 @@ export function getBootstrapChannelPlugin(id: ChannelId): ChannelPlugin | undefi
if (registry.missingIds.has(resolvedId)) {
return undefined;
}
const runtimePlugin = getBundledChannelPlugin(resolvedId);
const setupPlugin = getBundledChannelSetupPlugin(resolvedId);
let runtimePlugin: ChannelPlugin | undefined;
let setupPlugin: ChannelPlugin | undefined;
try {
runtimePlugin = getBundledChannelPlugin(resolvedId);
setupPlugin = getBundledChannelSetupPlugin(resolvedId);
} catch {
registry.missingIds.add(resolvedId);
return undefined;
}
const merged =
runtimePlugin && setupPlugin
? mergeBootstrapPlugin(runtimePlugin, setupPlugin)
@@ -154,11 +161,21 @@ export function getBootstrapChannelSecrets(id: ChannelId): ChannelPlugin["secret
if (registry.secretsById.has(resolvedId)) {
return undefined;
}
const runtimeSecrets = getBundledChannelSecrets(resolvedId);
const setupSecrets = getBundledChannelSetupSecrets(resolvedId);
const merged = mergePluginSection(runtimeSecrets, setupSecrets);
registry.secretsById.set(resolvedId, merged ?? null);
return merged;
if (registry.missingIds.has(resolvedId)) {
registry.secretsById.set(resolvedId, null);
return undefined;
}
try {
const runtimeSecrets = getBundledChannelSecrets(resolvedId);
const setupSecrets = getBundledChannelSetupSecrets(resolvedId);
const merged = mergePluginSection(runtimeSecrets, setupSecrets);
registry.secretsById.set(resolvedId, merged ?? null);
return merged;
} catch {
registry.missingIds.add(resolvedId);
registry.secretsById.set(resolvedId, null);
return undefined;
}
}
export function clearBootstrapChannelPluginCache(): void {

View File

@@ -144,4 +144,78 @@ describe("bundled root-aware caches", () => {
bootstrapRegistry.getBootstrapChannelSecrets("beta")?.secretTargetRegistryEntries?.[0]?.id,
).toBe("setup-beta-B");
});
it("marks bundled plugin ids missing when bootstrap plugin loading throws", async () => {
const root = makeBundledRoot("openclaw-bootstrap-plugin-throw-");
vi.doMock("./bundled-ids.js", () => ({
listBundledChannelPluginIdsForRoot: (cacheKey: string) =>
cacheKey === root.pluginsDir ? ["alpha"] : [],
}));
const getBundledChannelPluginMock = vi.fn(() => {
throw new Error("Cannot find module 'nostr-tools'");
});
const getBundledChannelSecretsMock = vi.fn(() => {
throw new Error("secrets should not load after plugin is marked missing");
});
vi.doMock("./bundled.js", () => ({
getBundledChannelPlugin: getBundledChannelPluginMock,
getBundledChannelSetupPlugin: vi.fn(() => undefined),
getBundledChannelSecrets: getBundledChannelSecretsMock,
getBundledChannelSetupSecrets: vi.fn(() => undefined),
}));
const bootstrapRegistry = await importFreshModule<typeof import("./bootstrap-registry.js")>(
import.meta.url,
"./bootstrap-registry.js?scope=bootstrap-plugin-load-guard",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = root.pluginsDir;
expect(bootstrapRegistry.listBootstrapChannelPluginIds()).toEqual(["alpha"]);
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined();
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined();
expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined();
expect(getBundledChannelPluginMock).toHaveBeenCalledTimes(1);
expect(getBundledChannelSecretsMock).not.toHaveBeenCalled();
});
it("marks bundled plugin ids missing when bootstrap secrets loading throws", async () => {
const root = makeBundledRoot("openclaw-bootstrap-secrets-throw-");
vi.doMock("./bundled-ids.js", () => ({
listBundledChannelPluginIdsForRoot: (cacheKey: string) =>
cacheKey === root.pluginsDir ? ["alpha"] : [],
}));
const getBundledChannelSecretsMock = vi.fn(() => {
throw new Error("Cannot find module '@larksuiteoapi/node-sdk'");
});
const getBundledChannelPluginMock = vi.fn(() => ({
id: "alpha",
meta: { id: "alpha", label: "Alpha" },
capabilities: {},
config: {},
}));
vi.doMock("./bundled.js", () => ({
getBundledChannelPlugin: getBundledChannelPluginMock,
getBundledChannelSetupPlugin: vi.fn(() => undefined),
getBundledChannelSecrets: getBundledChannelSecretsMock,
getBundledChannelSetupSecrets: vi.fn(() => undefined),
}));
const bootstrapRegistry = await importFreshModule<typeof import("./bootstrap-registry.js")>(
import.meta.url,
"./bootstrap-registry.js?scope=bootstrap-secrets-load-guard",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = root.pluginsDir;
expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined();
expect(bootstrapRegistry.getBootstrapChannelSecrets("alpha")).toBeUndefined();
expect(bootstrapRegistry.getBootstrapChannelPlugin("alpha")).toBeUndefined();
expect(getBundledChannelSecretsMock).toHaveBeenCalledTimes(1);
expect(getBundledChannelPluginMock).not.toHaveBeenCalled();
});
});

View File

@@ -635,6 +635,95 @@ describe("bundled channel entry shape guards", () => {
}
});
it("swallows and caches bundled plugin and setup load failures", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-load-failure-"));
const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const pluginDir = path.join(root, "dist", "extensions", "alpha");
const testGlobal = globalThis as typeof globalThis & {
__bundledPluginFailureLoads?: number;
__bundledSetupFailureLoads?: number;
__bundledSecretsFailureLoads?: number;
__bundledSetupSecretsFailureLoads?: number;
};
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.21" }),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "index.js"),
[
"export default {",
" kind: 'bundled-channel-entry',",
" id: 'alpha',",
" name: 'Alpha',",
" description: 'Alpha',",
" register() {},",
" loadChannelSecrets() {",
" globalThis.__bundledSecretsFailureLoads = (globalThis.__bundledSecretsFailureLoads ?? 0) + 1;",
" throw new Error('missing channel secrets dep');",
" },",
" loadChannelPlugin() {",
" globalThis.__bundledPluginFailureLoads = (globalThis.__bundledPluginFailureLoads ?? 0) + 1;",
" throw new Error('missing channel plugin dep');",
" },",
"};",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(
path.join(pluginDir, "setup-entry.js"),
[
"export default {",
" kind: 'bundled-channel-setup-entry',",
" loadSetupSecrets() {",
" globalThis.__bundledSetupSecretsFailureLoads = (globalThis.__bundledSetupSecretsFailureLoads ?? 0) + 1;",
" throw new Error('missing setup secrets dep');",
" },",
" loadSetupPlugin() {",
" globalThis.__bundledSetupFailureLoads = (globalThis.__bundledSetupFailureLoads ?? 0) + 1;",
" throw new Error('missing setup plugin dep');",
" },",
"};",
"",
].join("\n"),
"utf8",
);
mockAlphaDistExtensionRuntime();
try {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions");
const bundled = await importFreshModule<typeof import("./bundled.js")>(
import.meta.url,
"./bundled.js?scope=bundled-load-failure",
);
expect(bundled.getBundledChannelPlugin("alpha")).toBeUndefined();
expect(bundled.getBundledChannelPlugin("alpha")).toBeUndefined();
expect(bundled.getBundledChannelSetupPlugin("alpha")).toBeUndefined();
expect(bundled.getBundledChannelSetupPlugin("alpha")).toBeUndefined();
expect(bundled.getBundledChannelSecrets("alpha")).toBeUndefined();
expect(bundled.getBundledChannelSecrets("alpha")).toBeUndefined();
expect(bundled.getBundledChannelSetupSecrets("alpha")).toBeUndefined();
expect(bundled.getBundledChannelSetupSecrets("alpha")).toBeUndefined();
expect(testGlobal.__bundledPluginFailureLoads).toBe(1);
expect(testGlobal.__bundledSetupFailureLoads).toBe(1);
expect(testGlobal.__bundledSecretsFailureLoads).toBe(1);
expect(testGlobal.__bundledSetupSecretsFailureLoads).toBe(1);
} finally {
restoreBundledPluginsDir(previousBundledPluginsDir);
fs.rmSync(root, { recursive: true, force: true });
delete testGlobal.__bundledPluginFailureLoads;
delete testGlobal.__bundledSetupFailureLoads;
delete testGlobal.__bundledSecretsFailureLoads;
delete testGlobal.__bundledSetupSecretsFailureLoads;
}
});
it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => {
const offenders = collectBundledChannelEntrypointOffenders(
bundledPluginRoots,

View File

@@ -66,8 +66,8 @@ type BundledChannelCacheContext = {
setupEntryLoadInProgressIds: Set<ChannelId>;
lazyEntriesById: Map<ChannelId, GeneratedBundledChannelEntry | null>;
lazySetupEntriesById: Map<ChannelId, BundledChannelSetupEntryRuntimeContract | null>;
lazyPluginsById: Map<ChannelId, ChannelPlugin>;
lazySetupPluginsById: Map<ChannelId, ChannelPlugin>;
lazyPluginsById: Map<ChannelId, ChannelPlugin | null>;
lazySetupPluginsById: Map<ChannelId, ChannelPlugin | null>;
lazySecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
lazySetupSecretsById: Map<ChannelId, ChannelPlugin["secrets"] | null>;
lazyAccountInspectorsById: Map<
@@ -461,9 +461,8 @@ function getBundledChannelPluginForRoot(
rootScope: BundledChannelRootScope,
cacheContext: BundledChannelCacheContext,
): ChannelPlugin | undefined {
const cached = cacheContext.lazyPluginsById.get(id);
if (cached) {
return cached;
if (cacheContext.lazyPluginsById.has(id)) {
return cacheContext.lazyPluginsById.get(id) ?? undefined;
}
if (cacheContext.pluginLoadInProgressIds.has(id)) {
return undefined;
@@ -486,6 +485,11 @@ function getBundledChannelPluginForRoot(
};
cacheContext.lazyPluginsById.set(id, normalizedPlugin);
return normalizedPlugin;
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(`[channels] failed to load bundled channel ${id}: ${detail}`);
cacheContext.lazyPluginsById.set(id, null);
return undefined;
} finally {
cacheContext.pluginLoadInProgressIds.delete(id);
}
@@ -503,11 +507,18 @@ function getBundledChannelSecretsForRoot(
if (!entry) {
return undefined;
}
const secrets =
entry.loadChannelSecrets?.() ??
getBundledChannelPluginForRoot(id, rootScope, cacheContext)?.secrets;
cacheContext.lazySecretsById.set(id, secrets ?? null);
return secrets;
try {
const secrets =
entry.loadChannelSecrets?.() ??
getBundledChannelPluginForRoot(id, rootScope, cacheContext)?.secrets;
cacheContext.lazySecretsById.set(id, secrets ?? null);
return secrets;
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(`[channels] failed to load bundled channel secrets ${id}: ${detail}`);
cacheContext.lazySecretsById.set(id, null);
return undefined;
}
}
function getBundledChannelAccountInspectorForRoot(
@@ -523,9 +534,16 @@ function getBundledChannelAccountInspectorForRoot(
cacheContext.lazyAccountInspectorsById.set(id, null);
return undefined;
}
const inspector = entry.loadChannelAccountInspector();
cacheContext.lazyAccountInspectorsById.set(id, inspector);
return inspector;
try {
const inspector = entry.loadChannelAccountInspector();
cacheContext.lazyAccountInspectorsById.set(id, inspector);
return inspector;
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(`[channels] failed to load bundled channel account inspector ${id}: ${detail}`);
cacheContext.lazyAccountInspectorsById.set(id, null);
return undefined;
}
}
function getBundledChannelSetupPluginForRoot(
@@ -533,9 +551,8 @@ function getBundledChannelSetupPluginForRoot(
rootScope: BundledChannelRootScope,
cacheContext: BundledChannelCacheContext,
): ChannelPlugin | undefined {
const cached = cacheContext.lazySetupPluginsById.get(id);
if (cached) {
return cached;
if (cacheContext.lazySetupPluginsById.has(id)) {
return cacheContext.lazySetupPluginsById.get(id) ?? undefined;
}
if (cacheContext.setupPluginLoadInProgressIds.has(id)) {
return undefined;
@@ -549,6 +566,11 @@ function getBundledChannelSetupPluginForRoot(
const plugin = entry.loadSetupPlugin();
cacheContext.lazySetupPluginsById.set(id, plugin);
return plugin;
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(`[channels] failed to load bundled channel setup ${id}: ${detail}`);
cacheContext.lazySetupPluginsById.set(id, null);
return undefined;
} finally {
cacheContext.setupPluginLoadInProgressIds.delete(id);
}
@@ -566,11 +588,18 @@ function getBundledChannelSetupSecretsForRoot(
if (!entry) {
return undefined;
}
const secrets =
entry.loadSetupSecrets?.() ??
getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext)?.secrets;
cacheContext.lazySetupSecretsById.set(id, secrets ?? null);
return secrets;
try {
const secrets =
entry.loadSetupSecrets?.() ??
getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext)?.secrets;
cacheContext.lazySetupSecretsById.set(id, secrets ?? null);
return secrets;
} catch (error) {
const detail = formatErrorMessage(error);
log.warn(`[channels] failed to load bundled channel setup secrets ${id}: ${detail}`);
cacheContext.lazySetupSecretsById.set(id, null);
return undefined;
}
}
export function listBundledChannelPlugins(): readonly ChannelPlugin[] {