From b1f8172867f368079f654ca56ba420fb916e762c Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Sat, 2 May 2026 23:48:11 -0500 Subject: [PATCH] fix(secretrefs): resolve external channel contracts (#76449) --- CHANGELOG.md | 1 + extensions/bluebubbles/src/monitor.ts | 3 +- .../src/monitor.webhook-auth.test.ts | 10 ++ .../discord/src/setup-account-state.test.ts | 22 +++ extensions/telegram/src/accounts.test.ts | 22 +++ extensions/telegram/src/accounts.ts | 8 +- src/agents/tools/web-search.ts | 6 +- .../tools/web-tools.enabled-defaults.test.ts | 15 +- src/cli/command-secret-targets.ts | 15 ++ .../channel-contract-api.external.test.ts | 116 +++++++++++++ src/secrets/channel-contract-api.ts | 161 ++++++++++++++++++ ...runtime-config-collectors-channels.test.ts | 24 ++- .../runtime-config-collectors-channels.ts | 11 +- src/secrets/runtime-config-collectors.ts | 1 + ...-external-channel-origin-discovery.test.ts | 80 +++++++++ src/secrets/runtime.ts | 12 +- src/secrets/target-registry-data.ts | 18 +- src/secrets/target-registry-query.ts | 30 ++-- 18 files changed, 517 insertions(+), 38 deletions(-) create mode 100644 src/secrets/channel-contract-api.external.test.ts create mode 100644 src/secrets/runtime-external-channel-origin-discovery.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1479610f9..abfada25e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/secrets: resolve SecretRef-backed channel credentials through external plugin secret contracts after the plugin split, covering runtime startup, target discovery, webhook auth, disabled-account enumeration, and late-bound web_search config. (#76449) Thanks @joshavant. - Docker/Gateway: pass Docker setup `.env` values into gateway and CLI containers and preserve exec SecretRef `passEnv` keys in managed service plans, so 1Password Connect-backed Discord tokens keep resolving after doctor or plugin repair. Thanks @vincentkoc. - Control UI/WebChat: explain compaction boundaries in chat history and link directly to session checkpoint controls so pre-compaction turns no longer look silently lost after refresh. Fixes #76415. Thanks @BunsDev. - Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc. diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 0ba23f7f2f6..b86fe08b4c7 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -23,6 +23,7 @@ import { } from "./monitor-shared.js"; import { fetchBlueBubblesServerInfo } from "./probe.js"; import { getBlueBubblesRuntime } from "./runtime.js"; +import { normalizeSecretInputString } from "./secret-input.js"; import { WEBHOOK_RATE_LIMIT_DEFAULTS, createFixedWindowRateLimiter, @@ -193,7 +194,7 @@ export async function handleBlueBubblesWebhookRequest( targets, res, isMatch: (target) => { - const token = target.account.config.password?.trim() ?? ""; + const token = normalizeSecretInputString(target.account.config.password) ?? ""; return safeEqualAuthToken(guid, token); }, }); diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index f5bfa57a279..e9e02d7d607 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -432,6 +432,16 @@ describe("BlueBubbles webhook monitor", () => { ); }); + it("rejects unresolved SecretRef webhook passwords without crashing", async () => { + setupWebhookTarget({ + account: createMockAccount({ + password: { source: "exec", provider: "vault", id: "bluebubbles/webhook" } as never, + }), + }); + + await expectProtectedPasswordQueryRequestStatus(401); + }); + it("rate limits repeated invalid password guesses from the same client", async () => { setupWebhookTarget({ account: createMockAccount({ diff --git a/extensions/discord/src/setup-account-state.test.ts b/extensions/discord/src/setup-account-state.test.ts index 2d85545b8e0..143d6eabdf6 100644 --- a/extensions/discord/src/setup-account-state.test.ts +++ b/extensions/discord/src/setup-account-state.test.ts @@ -88,4 +88,26 @@ describe("discord setup account state", () => { expect(inspected.tokenStatus).toBe("missing"); expect(inspected.configured).toBe(false); }); + + it("reports unresolved SecretRef account tokens as configured but unavailable", () => { + const inspected = inspectDiscordSetupAccount({ + cfg: { + channels: { + discord: { + accounts: { + work: { + token: { source: "exec", provider: "vault", id: "discord/work" }, + }, + }, + }, + }, + }, + accountId: "work", + }); + + expect(inspected.token).toBe(""); + expect(inspected.tokenSource).toBe("config"); + expect(inspected.tokenStatus).toBe("configured_unavailable"); + expect(inspected.configured).toBe(true); + }); }); diff --git a/extensions/telegram/src/accounts.test.ts b/extensions/telegram/src/accounts.test.ts index a58b1c5087c..2ed1cbba227 100644 --- a/extensions/telegram/src/accounts.test.ts +++ b/extensions/telegram/src/accounts.test.ts @@ -4,6 +4,7 @@ import { withEnv } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTelegramActionGate, + listEnabledTelegramAccounts, listTelegramAccountIds, mergeTelegramAccountConfig, resolveTelegramMediaRuntimeOptions, @@ -123,6 +124,27 @@ describe("resolveTelegramAccount", () => { expect(lines).toContain("listTelegramAccountIds [ 'work' ]"); expect(lines).toContain("resolve { accountId: 'work', enabled: true, tokenSource: 'config' }"); }); + + it("does not resolve disabled account tokens when listing enabled accounts", () => { + const cfg = { + channels: { + telegram: { + accounts: { + disabled: { + enabled: false, + botToken: { source: "exec", provider: "vault", id: "telegram/disabled" }, + }, + work: { botToken: "tok-work" }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const accounts = listEnabledTelegramAccounts(cfg); + + expect(accounts.map((account) => account.accountId)).toEqual(["work"]); + expect(accounts[0]?.token).toBe("tok-work"); + }); }); describe("resolveDefaultTelegramAccountId", () => { diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index ade877db7d2..ddb86d84aa4 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -177,7 +177,11 @@ export function resolveTelegramAccount(params: { } export function listEnabledTelegramAccounts(cfg: OpenClawConfig): ResolvedTelegramAccount[] { + const baseEnabled = cfg.channels?.telegram?.enabled !== false; + if (!baseEnabled) { + return []; + } return listTelegramAccountIds(cfg) - .map((accountId) => resolveTelegramAccount({ cfg, accountId })) - .filter((account) => account.enabled); + .filter((accountId) => mergeTelegramAccountConfig(cfg, accountId).enabled !== false) + .map((accountId) => resolveTelegramAccount({ cfg, accountId })); } diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index d671fa3d5e4..451648e6c95 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js"; import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js"; import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js"; +import { getActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; import { resolveWebSearchProviderId, runWebSearch } from "../../web-search/runtime.js"; import type { AnyAgentTool } from "./common.js"; import { asToolParamsRecord, jsonResult } from "./common.js"; @@ -92,7 +93,10 @@ export function createWebSearchTool(options?: { : options?.runtimeWebSearch; const runtimeProviderId = runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured; - const config = options?.lateBindRuntimeConfig === true ? undefined : options?.config; + const config = + options?.lateBindRuntimeConfig === true + ? (getActiveSecretsRuntimeSnapshot()?.config ?? options?.config) + : options?.config; const preferRuntimeProviders = Boolean(runtimeProviderId) && !resolveManifestContractOwnerPluginId({ diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 2db169c22b0..39df65d87f7 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -10,6 +10,13 @@ import { createWebFetchTool, createWebSearchTool } from "./web-tools.js"; const runWebSearchCalls = vi.hoisted( () => [] as Array<{ config?: unknown; runtimeWebSearch?: unknown }>, ); +const activeSecretsRuntimeSnapshot = vi.hoisted(() => ({ + current: null as null | { config: unknown }, +})); + +vi.mock("../../secrets/runtime.js", () => ({ + getActiveSecretsRuntimeSnapshot: () => activeSecretsRuntimeSnapshot.current, +})); vi.mock("../../web-search/runtime.js", async () => { const { getActivePluginRegistry } = await import("../../plugins/runtime.js"); @@ -68,12 +75,14 @@ vi.mock("../../web-search/runtime.js", async () => { beforeEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); clearActiveRuntimeWebToolsMetadata(); + activeSecretsRuntimeSnapshot.current = null; runWebSearchCalls.length = 0; }); afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); clearActiveRuntimeWebToolsMetadata(); + activeSecretsRuntimeSnapshot.current = null; }); describe("web tools defaults", () => { @@ -196,6 +205,10 @@ describe("web tools defaults", () => { }, diagnostics: [], }); + const runtimeConfig = { + tools: { web: { search: { provider: "fresh", fresh: { apiKey: "runtime-key" } } } }, + }; + activeSecretsRuntimeSnapshot.current = { config: runtimeConfig }; const tool = createWebSearchTool({ config: { tools: { web: { search: { provider: "stale" } } } }, @@ -214,7 +227,7 @@ describe("web tools defaults", () => { expect(result?.details).toMatchObject({ provider: "fresh" }); expect(runWebSearchCalls).toHaveLength(1); - expect(runWebSearchCalls[0]?.config).toBeUndefined(); + expect(runWebSearchCalls[0]?.config).toBe(runtimeConfig); expect(runWebSearchCalls[0]?.runtimeWebSearch).toMatchObject({ selectedProvider: "fresh", }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index 9bbed1c5707..3d845d68597 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -1,6 +1,7 @@ import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-only.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalAccountId } from "../routing/session-key.js"; +import { loadChannelSecretContractApi } from "../secrets/channel-contract-api.js"; import { discoverConfigSecretTargetsByIds, listSecretTargetRegistryEntries, @@ -115,6 +116,20 @@ function getConfiguredChannelSecretTargetIds( env: NodeJS.ProcessEnv = process.env, ): string[] { const targetIds = new Set(); + const channels = config.channels; + if (channels && typeof channels === "object" && !Array.isArray(channels)) { + for (const channelId of Object.keys(channels)) { + if (channelId === "defaults") { + continue; + } + const contract = loadChannelSecretContractApi({ channelId, config, env }); + for (const entry of contract?.secretTargetRegistryEntries ?? []) { + if (isScopedChannelSecretTargetEntry({ entry, pluginChannelId: channelId })) { + targetIds.add(entry.id); + } + } + } + } for (const plugin of listReadOnlyChannelPluginsForConfig(config, { env, includePersistedAuthState: false, diff --git a/src/secrets/channel-contract-api.external.test.ts b/src/secrets/channel-contract-api.external.test.ts new file mode 100644 index 00000000000..47a4b9a16ad --- /dev/null +++ b/src/secrets/channel-contract-api.external.test.ts @@ -0,0 +1,116 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../plugins/test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +const { loadPluginMetadataSnapshotMock, loadBundledPluginPublicArtifactModuleSyncMock } = + vi.hoisted(() => ({ + loadPluginMetadataSnapshotMock: vi.fn(), + loadBundledPluginPublicArtifactModuleSyncMock: vi.fn(() => { + throw new Error( + "Unable to resolve bundled plugin public surface discord/secret-contract-api.js", + ); + }), + })); + +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, +})); + +vi.mock("../plugins/public-surface-loader.js", () => ({ + loadBundledPluginPublicArtifactModuleSync: loadBundledPluginPublicArtifactModuleSyncMock, +})); + +import { loadChannelSecretContractApi } from "./channel-contract-api.js"; + +function writeExternalChannelPlugin(params: { pluginId: string; channelId: string }) { + const rootDir = makeTrackedTempDir("openclaw-channel-secret-contract", tempDirs); + fs.writeFileSync( + path.join(rootDir, "secret-contract-api.cjs"), + ` +module.exports = { + secretTargetRegistryEntries: [ + { + id: "channels.${params.channelId}.token", + targetType: "channels.${params.channelId}.token", + configFile: "openclaw.json", + pathPattern: "channels.${params.channelId}.token", + secretShape: "secret_input", + expectedResolvedValue: "string", + includeInPlan: true, + includeInConfigure: true, + includeInAudit: true + } + ], + collectRuntimeConfigAssignments(params) { + params.context.assignments.push({ + path: "channels.${params.channelId}.token", + ref: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + expected: "string", + apply() {} + }); + } +}; +`, + "utf8", + ); + return { + id: params.pluginId, + origin: "global", + channels: [params.channelId], + channelConfigs: {}, + rootDir, + }; +} + +describe("external channel secret contract api", () => { + beforeEach(() => { + loadPluginMetadataSnapshotMock.mockReset(); + loadBundledPluginPublicArtifactModuleSyncMock.mockClear(); + }); + + afterEach(() => { + cleanupTrackedTempDirs(tempDirs); + }); + + it("loads root secret-contract-api sidecars for external channel plugins", () => { + const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" }); + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [record], + }); + + const api = loadChannelSecretContractApi({ + channelId: "discord", + config: { channels: { discord: {} } }, + env: {}, + loadablePluginOrigins: new Map([["discord", "global"]]), + }); + + expect(api?.secretTargetRegistryEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "channels.discord.token", + }), + ]), + ); + expect(api?.collectRuntimeConfigAssignments).toBeTypeOf("function"); + }); + + it("skips external channel records outside the loadable plugin origin set", () => { + const record = writeExternalChannelPlugin({ pluginId: "discord", channelId: "discord" }); + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [record], + }); + + const api = loadChannelSecretContractApi({ + channelId: "discord", + config: { channels: { discord: {} } }, + env: {}, + loadablePluginOrigins: new Map([["other", "global"]]), + }); + + expect(api).toBeUndefined(); + }); +}); diff --git a/src/secrets/channel-contract-api.ts b/src/secrets/channel-contract-api.ts index 4401f131fc1..9f97ff59e78 100644 --- a/src/secrets/channel-contract-api.ts +++ b/src/secrets/channel-contract-api.ts @@ -1,4 +1,17 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; +import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; +import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { + createPluginModuleLoaderCache, + getCachedPluginModuleLoader, + type PluginModuleLoaderCache, +} from "../plugins/plugin-module-loader-cache.js"; +import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; import type { ResolverContext, SecretDefaults } from "./runtime-shared.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; @@ -21,6 +34,13 @@ type BundledChannelContractApi = { ) => UnsupportedSecretRefConfigCandidate[]; }; +const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const; +const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url); +const RUNNING_FROM_BUILT_ARTIFACT = + CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) || + CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`); +const moduleLoaders: PluginModuleLoaderCache = createPluginModuleLoaderCache(); + function loadBundledChannelPublicArtifact( channelId: string, artifactBasenames: readonly string[], @@ -60,6 +80,147 @@ export function loadBundledChannelSecretContractApi( return loadBundledChannelPublicArtifact(channelId, ["secret-contract-api.js", "contract-api.js"]); } +function orderedContractApiExtensions(): readonly string[] { + return RUNNING_FROM_BUILT_ARTIFACT + ? CONTRACT_API_EXTENSIONS + : ([...CONTRACT_API_EXTENSIONS.slice(3), ...CONTRACT_API_EXTENSIONS.slice(0, 3)] as const); +} + +function resolvePluginContractApiPath(rootDir: string): string | null { + for (const extension of orderedContractApiExtensions()) { + const candidate = path.join(rootDir, `secret-contract-api${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + for (const extension of orderedContractApiExtensions()) { + const candidate = path.join(rootDir, `contract-api${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function loadPluginContractModule(modulePath: string): BundledChannelContractApi { + return getCachedPluginModuleLoader({ + cache: moduleLoaders, + modulePath, + importerUrl: import.meta.url, + })(modulePath) as BundledChannelContractApi; +} + +function loadExternalChannelSecretContractFromRecord( + record: PluginManifestRecord, +): BundledChannelSecretContractApi | undefined { + const contractPath = resolvePluginContractApiPath(record.rootDir); + if (!contractPath) { + return undefined; + } + const opened = openBoundaryFileSync({ + absolutePath: contractPath, + rootPath: record.rootDir, + boundaryLabel: "plugin root", + rejectHardlinks: record.origin !== "bundled", + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + return undefined; + } + const safePath = opened.path; + fs.closeSync(opened.fd); + try { + const mod = loadPluginContractModule(safePath); + if (mod.collectRuntimeConfigAssignments || mod.secretTargetRegistryEntries) { + return mod; + } + } catch (error) { + if (process.env.OPENCLAW_DEBUG_CHANNEL_CONTRACT_API === "1") { + const detail = error instanceof Error ? error.message : String(error); + process.stderr.write( + `[channel-contract-api] failed to load ${record.id} contract ${safePath}: ${detail}\n`, + ); + } + } + return undefined; +} + +function recordOwnsChannel(record: PluginManifestRecord, channelId: string): boolean { + return ( + record.channels.includes(channelId) || + Object.prototype.hasOwnProperty.call(record.channelConfigs ?? {}, channelId) || + record.channelCatalogMeta?.id === channelId || + record.packageChannel?.id === channelId + ); +} + +function listChannelSecretContractRecords(params: { + channelId: string; + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + loadablePluginOrigins?: ReadonlyMap; +}): PluginManifestRecord[] { + const workspaceDir = resolveAgentWorkspaceDir( + params.config, + resolveDefaultAgentId(params.config), + params.env, + ); + const snapshot = loadPluginMetadataSnapshot({ + config: params.config, + workspaceDir, + env: params.env, + }); + return snapshot.plugins + .filter((record) => record.origin !== "bundled") + .filter((record) => recordOwnsChannel(record, params.channelId)) + .filter( + (record) => !params.loadablePluginOrigins || params.loadablePluginOrigins.has(record.id), + ) + .toSorted((left, right) => { + if (left.id === params.channelId && right.id !== params.channelId) { + return -1; + } + if (right.id === params.channelId && left.id !== params.channelId) { + return 1; + } + return left.id.localeCompare(right.id); + }); +} + +export function loadChannelSecretContractApi(params: { + channelId: string; + config: OpenClawConfig; + env?: NodeJS.ProcessEnv; + loadablePluginOrigins?: ReadonlyMap; +}): BundledChannelSecretContractApi | undefined { + const bundled = loadBundledChannelSecretContractApi(params.channelId); + if (bundled) { + return bundled; + } + const env = params.env ?? process.env; + for (const record of listChannelSecretContractRecords({ + channelId: params.channelId, + config: params.config, + env, + loadablePluginOrigins: params.loadablePluginOrigins, + })) { + const contract = loadExternalChannelSecretContractFromRecord(record); + if (contract) { + return contract; + } + } + return undefined; +} + +export function loadChannelSecretContractApiForRecord( + record: PluginManifestRecord, +): BundledChannelSecretContractApi | undefined { + if (record.origin === "bundled") { + return loadBundledChannelSecretContractApi(record.id); + } + return loadExternalChannelSecretContractFromRecord(record); +} + export type BundledChannelSecurityContractApi = Pick< BundledChannelContractApi, "unsupportedSecretRefSurfacePatterns" | "collectUnsupportedSecretRefConfigCandidates" diff --git a/src/secrets/runtime-config-collectors-channels.test.ts b/src/secrets/runtime-config-collectors-channels.test.ts index a44522603e8..0e9756873ec 100644 --- a/src/secrets/runtime-config-collectors-channels.test.ts +++ b/src/secrets/runtime-config-collectors-channels.test.ts @@ -3,27 +3,27 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ResolverContext } from "./runtime-shared.js"; const getBootstrapChannelSecrets = vi.fn(); -const loadBundledChannelSecretContractApi = vi.fn(); +const loadChannelSecretContractApi = vi.fn(); vi.mock("../channels/plugins/bootstrap-registry.js", () => ({ getBootstrapChannelSecrets, })); vi.mock("./channel-contract-api.js", () => ({ - loadBundledChannelSecretContractApi, + loadChannelSecretContractApi, })); describe("runtime channel config collectors", () => { beforeEach(() => { getBootstrapChannelSecrets.mockReset(); - loadBundledChannelSecretContractApi.mockReset(); + loadChannelSecretContractApi.mockReset(); }); it("uses the bundled channel contract-api collector when bootstrap secrets are unavailable", async () => { const { collectChannelConfigAssignments } = await import("./runtime-config-collectors-channels.js"); const collectRuntimeConfigAssignments = vi.fn(); - loadBundledChannelSecretContractApi.mockReturnValue({ + loadChannelSecretContractApi.mockReturnValue({ collectRuntimeConfigAssignments, }); getBootstrapChannelSecrets.mockReturnValue(undefined); @@ -42,7 +42,12 @@ describe("runtime channel config collectors", () => { context: {} as ResolverContext, }); - expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("bluebubbles"); + expect(loadChannelSecretContractApi).toHaveBeenCalledWith({ + channelId: "bluebubbles", + config: expect.any(Object), + env: undefined, + loadablePluginOrigins: undefined, + }); expect(collectRuntimeConfigAssignments).toHaveBeenCalledOnce(); expect(getBootstrapChannelSecrets).not.toHaveBeenCalled(); }); @@ -51,7 +56,7 @@ describe("runtime channel config collectors", () => { const { collectChannelConfigAssignments } = await import("./runtime-config-collectors-channels.js"); const collectRuntimeConfigAssignments = vi.fn(); - loadBundledChannelSecretContractApi.mockReturnValue(undefined); + loadChannelSecretContractApi.mockReturnValue(undefined); getBootstrapChannelSecrets.mockReturnValue({ collectRuntimeConfigAssignments, }); @@ -66,7 +71,12 @@ describe("runtime channel config collectors", () => { context: {} as ResolverContext, }); - expect(loadBundledChannelSecretContractApi).toHaveBeenCalledWith("legacy"); + expect(loadChannelSecretContractApi).toHaveBeenCalledWith({ + channelId: "legacy", + config: expect.any(Object), + env: undefined, + loadablePluginOrigins: undefined, + }); expect(getBootstrapChannelSecrets).toHaveBeenCalledWith("legacy"); expect(collectRuntimeConfigAssignments).toHaveBeenCalledOnce(); }); diff --git a/src/secrets/runtime-config-collectors-channels.ts b/src/secrets/runtime-config-collectors-channels.ts index a3211932633..cef2ae2db6e 100644 --- a/src/secrets/runtime-config-collectors-channels.ts +++ b/src/secrets/runtime-config-collectors-channels.ts @@ -1,19 +1,26 @@ import { getBootstrapChannelSecrets } from "../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +import type { PluginOrigin } from "../plugins/plugin-origin.types.js"; +import { loadChannelSecretContractApi } from "./channel-contract-api.js"; import { type ResolverContext, type SecretDefaults } from "./runtime-shared.js"; export function collectChannelConfigAssignments(params: { config: OpenClawConfig; defaults: SecretDefaults | undefined; context: ResolverContext; + loadablePluginOrigins?: ReadonlyMap; }): void { const channelIds = Object.keys(params.config.channels ?? {}); if (channelIds.length === 0) { return; } for (const channelId of channelIds) { - const contract = loadBundledChannelSecretContractApi(channelId); + const contract = loadChannelSecretContractApi({ + channelId, + config: params.config, + env: params.context.env, + loadablePluginOrigins: params.loadablePluginOrigins, + }); const collectRuntimeConfigAssignments = contract?.collectRuntimeConfigAssignments ?? getBootstrapChannelSecrets(channelId)?.collectRuntimeConfigAssignments; diff --git a/src/secrets/runtime-config-collectors.ts b/src/secrets/runtime-config-collectors.ts index e199f85d50e..342fbb43aaa 100644 --- a/src/secrets/runtime-config-collectors.ts +++ b/src/secrets/runtime-config-collectors.ts @@ -22,6 +22,7 @@ export function collectConfigAssignments(params: { config: params.config, defaults, context: params.context, + loadablePluginOrigins: params.loadablePluginOrigins, }); collectPluginConfigAssignments({ diff --git a/src/secrets/runtime-external-channel-origin-discovery.test.ts b/src/secrets/runtime-external-channel-origin-discovery.test.ts new file mode 100644 index 00000000000..dd5eda45c3e --- /dev/null +++ b/src/secrets/runtime-external-channel-origin-discovery.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; + +const { loadPluginMetadataSnapshotMock, loadChannelSecretContractApiMock } = vi.hoisted(() => ({ + loadPluginMetadataSnapshotMock: vi.fn(), + loadChannelSecretContractApiMock: vi.fn(), +})); + +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock, + listPluginOriginsFromMetadataSnapshot: (snapshot: { + plugins: Array<{ id: string; origin: string }>; + }) => new Map(snapshot.plugins.map((record) => [record.id, record.origin])), +})); + +vi.mock("./channel-contract-api.js", () => ({ + loadChannelSecretContractApi: loadChannelSecretContractApiMock, +})); + +import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts"; + +const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks(); + +describe("secrets runtime external channel origin discovery", () => { + it("discovers loadable plugins for channel SecretRefs when plugins.entries is absent", async () => { + loadPluginMetadataSnapshotMock.mockReturnValue({ + plugins: [{ id: "discord", origin: "global" }], + }); + loadChannelSecretContractApiMock.mockReturnValue({ + collectRuntimeConfigAssignments: (params: { + config: { channels?: { discord?: { token?: unknown } } }; + context: { + assignments: Array<{ + ref: { source: "env"; provider: "default"; id: string }; + path: string; + expected: "string"; + apply: (value: unknown) => void; + }>; + }; + }) => { + const token = params.config.channels?.discord?.token; + if (!token || typeof token !== "object" || Array.isArray(token)) { + return; + } + params.context.assignments.push({ + ref: token as { source: "env"; provider: "default"; id: string }, + path: "channels.discord.token", + expected: "string", + apply: (value) => { + if (params.config.channels?.discord) { + params.config.channels.discord.token = value; + } + }, + }); + }, + }); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config: asConfig({ + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }), + env: { + DISCORD_BOT_TOKEN: "resolved-discord-token", + }, + includeAuthStoreRefs: false, + }); + + expect(snapshot.config.channels?.discord?.token).toBe("resolved-discord-token"); + expect(loadPluginMetadataSnapshotMock).toHaveBeenCalled(); + expect(loadChannelSecretContractApiMock).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "discord", + loadablePluginOrigins: new Map([["discord", "global"]]), + }), + ); + }); +}); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index cf572a4d534..0bcc48386b7 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -176,6 +176,16 @@ function hasConfiguredPluginEntries(config: OpenClawConfig): boolean { ); } +function hasConfiguredChannelEntries(config: OpenClawConfig): boolean { + const channels = config.channels; + return ( + !!channels && + typeof channels === "object" && + !Array.isArray(channels) && + Object.keys(channels).some((channelId) => channelId !== "defaults") + ); +} + function createEmptyRuntimeWebToolsMetadata(): RuntimeWebToolsMetadata { return { search: { @@ -365,7 +375,7 @@ export async function prepareSecretsRuntimeSnapshot(params: { } = await loadRuntimePrepareHelpers(); const loadablePluginOrigins = params.loadablePluginOrigins ?? - (hasConfiguredPluginEntries(sourceConfig) + (hasConfiguredPluginEntries(sourceConfig) || hasConfiguredChannelEntries(sourceConfig) ? await resolveLoadablePluginOrigins({ config: sourceConfig, env: runtimeEnv }) : new Map()); const context = createResolverContext({ diff --git a/src/secrets/target-registry-data.ts b/src/secrets/target-registry-data.ts index 52cdd32a586..37d55553c1a 100644 --- a/src/secrets/target-registry-data.ts +++ b/src/secrets/target-registry-data.ts @@ -1,6 +1,6 @@ import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; -import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js"; import type { SecretTargetRegistryEntry } from "./target-registry-types.js"; const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret @@ -85,20 +85,20 @@ function listBundledPluginConfigSecretTargetRegistryEntries( } function listChannelSecretTargetRegistryEntries( - bundledPlugins: readonly PluginManifestRecord[], + channelPlugins: readonly PluginManifestRecord[], ): SecretTargetRegistryEntry[] { const entries: SecretTargetRegistryEntry[] = []; - for (const record of bundledPlugins) { + for (const record of channelPlugins) { const channelIds = record.channels; if (channelIds.length === 0) { continue; } try { - const contractApi = loadBundledChannelSecretContractApi(record.id); + const contractApi = loadChannelSecretContractApiForRecord(record); entries.push(...(contractApi?.secretTargetRegistryEntries ?? [])); } catch { - // Ignore bundled channels that do not expose a usable secret contract artifact. + // Ignore channels that do not expose a usable secret contract artifact. } } return entries; @@ -449,15 +449,17 @@ export function getSecretTargetRegistry(): SecretTargetRegistryEntry[] { if (cachedSecretTargetRegistry) { return cachedSecretTargetRegistry; } - const bundledPlugins = loadPluginMetadataSnapshot({ + const plugins = loadPluginMetadataSnapshot({ config: {}, env: process.env, - }).plugins.filter((record) => record.origin === "bundled"); + }).plugins; + const bundledPlugins = plugins.filter((record) => record.origin === "bundled"); + const channelPlugins = plugins.filter((record) => record.channels.length > 0); cachedSecretTargetRegistry = [ ...CORE_SECRET_TARGET_REGISTRY, ...listBundledWebProviderSecretTargetRegistryEntries(bundledPlugins), ...listBundledPluginConfigSecretTargetRegistryEntries(bundledPlugins), - ...listChannelSecretTargetRegistryEntries(bundledPlugins), + ...listChannelSecretTargetRegistryEntries(channelPlugins), ]; return cachedSecretTargetRegistry; } diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index f543992bc19..5be274aface 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -1,5 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { loadBundledChannelSecretContractApi } from "./channel-contract-api.js"; +import { loadChannelSecretContractApi } from "./channel-contract-api.js"; import { getPath } from "./path-utils.js"; import { getCoreSecretTargetRegistry, getSecretTargetRegistry } from "./target-registry-data.js"; import { @@ -32,10 +32,7 @@ let compiledCoreOpenClawTargetState: { targetsByType: Map; } | null = null; -const compiledBundledChannelOpenClawTargets = new Map< - string, - CompiledTargetRegistryEntry[] | null ->(); +const compiledChannelOpenClawTargets = new Map(); function buildTargetTypeIndex( compiledSecretTargetRegistry: CompiledTargetRegistryEntry[], @@ -112,21 +109,25 @@ function getCompiledCoreOpenClawTargetState() { return compiledCoreOpenClawTargetState; } -function getCompiledBundledChannelOpenClawTargets( +function getCompiledChannelOpenClawTargets( channelId: string, ): CompiledTargetRegistryEntry[] | null { const normalizedChannelId = channelId.trim(); if (!normalizedChannelId) { return null; } - if (compiledBundledChannelOpenClawTargets.has(normalizedChannelId)) { - return compiledBundledChannelOpenClawTargets.get(normalizedChannelId) ?? null; + if (compiledChannelOpenClawTargets.has(normalizedChannelId)) { + return compiledChannelOpenClawTargets.get(normalizedChannelId) ?? null; } const compiledEntries = - loadBundledChannelSecretContractApi(normalizedChannelId) + loadChannelSecretContractApi({ + channelId: normalizedChannelId, + config: {} as OpenClawConfig, + env: process.env, + }) ?.secretTargetRegistryEntries?.filter((entry) => entry.configFile === "openclaw.json") .map(compileTargetRegistryEntry) ?? null; - compiledBundledChannelOpenClawTargets.set(normalizedChannelId, compiledEntries); + compiledChannelOpenClawTargets.set(normalizedChannelId, compiledEntries); return compiledEntries; } @@ -327,12 +328,11 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve return resolved; } - const explicitBundledChannelId = - pathSegments[0] === "channels" ? (pathSegments[1]?.trim() ?? "") : ""; - const explicitBundledChannelEntries = explicitBundledChannelId - ? getCompiledBundledChannelOpenClawTargets(explicitBundledChannelId) + const explicitChannelId = pathSegments[0] === "channels" ? (pathSegments[1]?.trim() ?? "") : ""; + const explicitChannelEntries = explicitChannelId + ? getCompiledChannelOpenClawTargets(explicitChannelId) : null; - for (const entry of explicitBundledChannelEntries ?? []) { + for (const entry of explicitChannelEntries ?? []) { if (!entry.includeInPlan) { continue; }