From 06c23e36efbedb2d789287d959e20f8e2c299bca Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 21 Apr 2026 11:22:15 -0400 Subject: [PATCH] fix: distinguish read-only plugin options --- src/channels/plugins/read-only.test.ts | 27 +++++++++++++ src/channels/plugins/read-only.ts | 55 +++++++++++++++++++++----- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 8afa3f9a5f0..4d23a71a70d 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -366,6 +366,33 @@ describe("listReadOnlyChannelPluginsForConfig", () => { expect(fs.existsSync(fullMarker)).toBe(false); }); + it("treats process env maps with option-like keys as env maps", () => { + const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin({ + pluginId: "external-chat-plugin", + channelId: "external-chat", + }); + const plugins = listReadOnlyChannelPluginsForConfig( + { + plugins: { + load: { paths: [pluginDir] }, + allow: ["external-chat-plugin"], + }, + } as never, + { + ...process.env, + cache: "true", + env: "prod", + EXTERNAL_CHAT_TOKEN: "configured", + workspaceDir: "workspace-env-value", + } as NodeJS.ProcessEnv, + ); + + const plugin = plugins.find((entry) => entry.id === "external-chat"); + expect(plugin?.meta.blurb).toBe("setup entry"); + expect(fs.existsSync(setupMarker)).toBe(true); + expect(fs.existsSync(fullMarker)).toBe(false); + }); + it("discovers trusted external channel plugins from the default agent workspace", () => { const workspaceDir = makeTempDir(); const pluginDir = path.join(workspaceDir, ".openclaw", "extensions", "external-chat-plugin"); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index c64c8b3707d..998e478f8d1 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -27,22 +27,59 @@ type ReadOnlyChannelPluginResolution = { missingConfiguredChannelIds: string[]; }; +const READ_ONLY_CHANNEL_PLUGIN_OPTION_KEYS = new Set([ + "env", + "workspaceDir", + "activationSourceConfig", + "includePersistedAuthState", + "cache", +]); + +function hasOwnRecordKey(record: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function isRecordLike(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isReadOnlyChannelPluginOptions( + value: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, +): value is ReadOnlyChannelPluginOptions { + const record = value as Record; + if (hasOwnRecordKey(record, "env")) { + return record.env === undefined || isRecordLike(record.env); + } + if (hasOwnRecordKey(record, "activationSourceConfig")) { + return ( + record.activationSourceConfig === undefined || isRecordLike(record.activationSourceConfig) + ); + } + if (hasOwnRecordKey(record, "includePersistedAuthState")) { + return ( + record.includePersistedAuthState === undefined || + typeof record.includePersistedAuthState === "boolean" + ); + } + if (hasOwnRecordKey(record, "cache")) { + return record.cache === undefined || typeof record.cache === "boolean"; + } + if (hasOwnRecordKey(record, "workspaceDir")) { + return Object.keys(record).every((key) => READ_ONLY_CHANNEL_PLUGIN_OPTION_KEYS.has(key)); + } + return false; +} + function resolveReadOnlyChannelPluginOptions( envOrOptions?: NodeJS.ProcessEnv | ReadOnlyChannelPluginOptions, ): ReadOnlyChannelPluginOptions { if (!envOrOptions) { return {}; } - if ( - "env" in envOrOptions || - "workspaceDir" in envOrOptions || - "activationSourceConfig" in envOrOptions || - "includePersistedAuthState" in envOrOptions || - "cache" in envOrOptions - ) { - return envOrOptions as ReadOnlyChannelPluginOptions; + if (isReadOnlyChannelPluginOptions(envOrOptions)) { + return envOrOptions; } - return { env: envOrOptions as NodeJS.ProcessEnv }; + return { env: envOrOptions }; } function addChannelPlugins(