From 1558a352f86a67957c5f8b6a4bf410773f3b9a17 Mon Sep 17 00:00:00 2001 From: Mason Huang Date: Tue, 14 Apr 2026 22:51:22 +0800 Subject: [PATCH] fix(plugins): support bundled setup-entry contract in loader (#66261) Merged via squash. Prepared head SHA: 0a4201115c9a13767e8f7d1f7b3ff00743646463 Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 1 + src/plugins/loader.test.ts | 198 +++++++++++++++++++++++++++++++++++-- src/plugins/loader.ts | 67 +++++++++++++ 3 files changed, 258 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07aea968bc9..dd18b97d38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai - Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc. - CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana. - Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc. +- Plugins/setup-entry: preserve separate setup-entry secrets exports when loading bundled setup-runtime channels, so setup-mode flows keep the channel secret contract for split plugin + secrets entrypoints. (#66261) Thanks @hxy91819. ## 2026.4.12 diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index f25ec8e78fe..1dd91fca48f 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -522,6 +522,8 @@ function createSetupEntryChannelPluginFixture(params: { setupBlurb: string; configured: boolean; startupDeferConfiguredChannelFullLoadUntilAfterListen?: boolean; + useBundledSetupEntryContract?: boolean; + splitBundledSetupSecrets?: boolean; }) { useNoBundledPlugins(); const pluginDir = makeTempDir(); @@ -597,7 +599,40 @@ module.exports = { ); fs.writeFileSync( path.join(pluginDir, "setup-entry.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); + params.useBundledSetupEntryContract + ? `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); +module.exports = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin: () => ({ + id: ${JSON.stringify(params.id)}, + meta: { + id: ${JSON.stringify(params.id)}, + label: ${JSON.stringify(params.label)}, + selectionLabel: ${JSON.stringify(params.label)}, + docsPath: ${JSON.stringify(`/channels/${params.id}`)}, + blurb: ${JSON.stringify(params.setupBlurb)}, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ${listAccountIds}, + resolveAccount: () => ${resolveAccount}, + }, + outbound: { deliveryMode: "direct" }, + }), + ${ + params.splitBundledSetupSecrets + ? `loadSetupSecrets: () => ({ + secretTargetRegistryEntries: [ + { + id: ${JSON.stringify(`channels.${params.id}.setup-token`)}, + targetType: "channel", + }, + ], + }),` + : "" + } +};` + : `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); module.exports = { plugin: { id: ${JSON.stringify(params.id)}, @@ -3168,6 +3203,58 @@ module.exports = { expectSetupLoaded: true, expectedChannels: 1, }, + { + name: "uses package setupEntry bundled contract for setup-runtime channel loads", + fixture: { + id: "setup-runtime-bundled-contract-test", + label: "Setup Runtime Bundled Contract Test", + packageName: "@openclaw/setup-runtime-bundled-contract-test", + fullBlurb: "full entry should not run while unconfigured", + setupBlurb: "setup runtime bundled contract", + configured: false, + useBundledSetupEntryContract: true, + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-bundled-contract-test"], + }, + }, + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 1, + }, + { + name: "preserves bundled setupEntry split secrets for setup-runtime channel loads", + fixture: { + id: "setup-runtime-bundled-contract-secrets-test", + label: "Setup Runtime Bundled Contract Secrets Test", + packageName: "@openclaw/setup-runtime-bundled-contract-secrets-test", + fullBlurb: "full entry should not run while unconfigured", + setupBlurb: "setup runtime bundled contract secrets", + configured: false, + useBundledSetupEntryContract: true, + splitBundledSetupSecrets: true, + }, + load: ({ pluginDir }: { pluginDir: string }) => + loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-runtime-bundled-contract-secrets-test"], + }, + }, + }), + expectFullLoaded: false, + expectSetupLoaded: true, + expectedChannels: 1, + expectedSetupSecretId: "channels.setup-runtime-bundled-contract-secrets-test.setup-token", + }, { name: "does not prefer setupEntry for configured channel loads without startup opt-in", fixture: { @@ -3199,14 +3286,109 @@ module.exports = { expectSetupLoaded: false, expectedChannels: 1, }, - ])("$name", ({ fixture, load, expectFullLoaded, expectSetupLoaded, expectedChannels }) => { - const built = createSetupEntryChannelPluginFixture(fixture); - const registry = load({ pluginDir: built.pluginDir }); + ])( + "$name", + ({ + fixture, + load, + expectFullLoaded, + expectSetupLoaded, + expectedChannels, + expectedSetupSecretId, + }) => { + const built = createSetupEntryChannelPluginFixture(fixture); + const registry = load({ pluginDir: built.pluginDir }); - expect(fs.existsSync(built.fullMarker)).toBe(expectFullLoaded); - expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded); - expect(registry.channelSetups).toHaveLength(1); - expect(registry.channels).toHaveLength(expectedChannels); + expect(fs.existsSync(built.fullMarker)).toBe(expectFullLoaded); + expect(fs.existsSync(built.setupMarker)).toBe(expectSetupLoaded); + expect(registry.channelSetups).toHaveLength(1); + expect(registry.channels).toHaveLength(expectedChannels); + if (expectedSetupSecretId) { + expect(registry.channelSetups[0]?.plugin.secrets?.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expectedSetupSecretId, + }), + ]), + ); + expect(registry.channels[0]?.plugin.secrets?.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expectedSetupSecretId, + }), + ]), + ); + } + }, + ); + + it("isolates loadSetupPlugin errors as per-plugin diagnostics instead of crashing registry load", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + + // Plugin whose setup-entry uses the bundled contract but loadSetupPlugin() throws + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/setup-entry-throws-test", + openclaw: { + extensions: ["./index.cjs"], + setupEntry: "./setup-entry.cjs", + }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "setup-entry-throws-test", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["setup-entry-throws-test"], + }, + null, + 2, + ), + "utf-8", + ); + // index.cjs: full entry (should NOT be reached if setup-entry is used) + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `module.exports = { id: "setup-entry-throws-test", register() {} };`, + "utf-8", + ); + // setup-entry.cjs: bundled contract whose loadSetupPlugin throws + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `module.exports = { + kind: "bundled-channel-setup-entry", + loadSetupPlugin: () => { throw new Error("boom: setup plugin missing"); }, +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["setup-entry-throws-test"], + }, + }, + }); + + // The registry load should NOT crash; the error should be recorded as a + // per-plugin diagnostic rather than aborting the whole load. + expect(registry.diagnostics.length).toBeGreaterThanOrEqual(1); + const diagnostic = registry.diagnostics.find( + (d) => d.pluginId === "setup-entry-throws-test" && d.level === "error", + ); + expect(diagnostic).toBeDefined(); + expect(diagnostic!.message).toContain("failed to load setup entry"); }); it("prefers setupEntry for configured channel loads during startup when opted in", () => { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 5194c4c6224..f714118dc25 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -644,13 +644,65 @@ function resolvePluginModuleExport(moduleExport: unknown): { return {}; } +function mergeSetupPluginSection( + baseValue: T | undefined, + setupValue: T | undefined, +): T | undefined { + if (baseValue && setupValue && typeof baseValue === "object" && typeof setupValue === "object") { + const merged = { + ...(baseValue as Record), + }; + for (const [key, value] of Object.entries(setupValue as Record)) { + if (value !== undefined) { + merged[key] = value; + } + } + return { + ...merged, + } as T; + } + return setupValue ?? baseValue; +} + function resolveSetupChannelRegistration(moduleExport: unknown): { plugin?: ChannelPlugin; + loadError?: unknown; } { const resolved = unwrapDefaultModuleExport(moduleExport); if (!resolved || typeof resolved !== "object") { return {}; } + const setupEntryRecord = resolved as { + kind?: unknown; + loadSetupPlugin?: unknown; + loadSetupSecrets?: unknown; + }; + if ( + setupEntryRecord.kind === "bundled-channel-setup-entry" && + typeof setupEntryRecord.loadSetupPlugin === "function" + ) { + try { + const loadedPlugin = setupEntryRecord.loadSetupPlugin(); + const loadedSecrets = + typeof setupEntryRecord.loadSetupSecrets === "function" + ? (setupEntryRecord.loadSetupSecrets() as ChannelPlugin["secrets"] | undefined) + : undefined; + if (loadedPlugin && typeof loadedPlugin === "object") { + const mergedSecrets = mergeSetupPluginSection( + (loadedPlugin as ChannelPlugin).secrets, + loadedSecrets, + ); + return { + plugin: { + ...(loadedPlugin as ChannelPlugin), + ...(mergedSecrets !== undefined ? { secrets: mergedSecrets } : {}), + }, + }; + } + } catch (err) { + return { loadError: err }; + } + } const setup = resolved as { plugin?: unknown; }; @@ -1635,6 +1687,21 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi manifestRecord.setupSource ) { const setupRegistration = resolveSetupChannelRegistration(mod); + if (setupRegistration.loadError) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + phase: "load", + error: setupRegistration.loadError, + logPrefix: `[plugins] ${record.id} failed to load setup entry from ${record.source}: `, + diagnosticMessagePrefix: "failed to load setup entry: ", + }); + continue; + } if (setupRegistration.plugin) { if (setupRegistration.plugin.id && setupRegistration.plugin.id !== record.id) { pushPluginLoadError(