From 694bc082a8c4d3a1a0c2e418cd10b5ff7f1e68da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 30 Mar 2026 05:29:12 +0900 Subject: [PATCH] fix: resolve acpx MCP secret inputs --- docs/gateway/secrets.md | 33 ++ .../runtime-config-collectors-plugins.test.ts | 502 ++++++++++++++++++ .../runtime-config-collectors-plugins.ts | 118 ++++ src/secrets/runtime-config-collectors.ts | 10 + src/secrets/runtime.test.ts | 159 ++++++ src/secrets/runtime.ts | 40 +- 6 files changed, 861 insertions(+), 1 deletion(-) create mode 100644 src/secrets/runtime-config-collectors-plugins.test.ts create mode 100644 src/secrets/runtime-config-collectors-plugins.ts diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 94e40f6288d..6c338636d9e 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -288,6 +288,39 @@ Optional per-id errors: } ``` +## MCP server environment variables + +MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` support SecretInput. This keeps API keys and tokens out of plaintext config: + +```json5 +{ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + github: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: { + source: "env", + provider: "default", + id: "MCP_GITHUB_PAT", + }, + }, + }, + }, + }, + }, + }, + }, +} +``` + +Plaintext string values still work. Env-template refs like `${MCP_SERVER_API_KEY}` and SecretRef objects are resolved during gateway activation before the MCP server process is spawned. As with other SecretRef surfaces, unresolved refs only block activation when the `acpx` plugin is effectively active. + ## Sandbox SSH auth material The core `ssh` sandbox backend also supports SecretRefs for SSH auth material: diff --git a/src/secrets/runtime-config-collectors-plugins.test.ts b/src/secrets/runtime-config-collectors-plugins.test.ts new file mode 100644 index 00000000000..d3040a62e06 --- /dev/null +++ b/src/secrets/runtime-config-collectors-plugins.test.ts @@ -0,0 +1,502 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginOrigin } from "../plugins/types.js"; +import { collectPluginConfigAssignments } from "./runtime-config-collectors-plugins.js"; +import { + createResolverContext, + type ResolverContext, + type SecretDefaults, +} from "./runtime-shared.js"; + +function asConfig(value: unknown): OpenClawConfig { + return value as OpenClawConfig; +} + +function makeContext(sourceConfig: OpenClawConfig): ResolverContext { + return createResolverContext({ + sourceConfig, + env: {}, + }); +} + +function envRef(id: string) { + return { source: "env" as const, provider: "default", id }; +} + +function loadablePluginOrigins(entries: Array<[string, PluginOrigin]>) { + return new Map(entries); +} + +describe("collectPluginConfigAssignments", () => { + it("collects SecretRef assignments from active acpx MCP server env vars", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + github: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-github"], + env: { + GITHUB_TOKEN: envRef("GITHUB_TOKEN"), + PLAIN_VAR: "plain-value", + }, + }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + const defaults: SecretDefaults = undefined; + + collectPluginConfigAssignments({ + config, + defaults, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(1); + expect(context.assignments[0]?.path).toBe( + "plugins.entries.acpx.config.mcpServers.github.env.GITHUB_TOKEN", + ); + expect(context.assignments[0]?.expected).toBe("string"); + }); + + it("resolves assignments via apply callback", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + mcp1: { + command: "node", + env: { + API_KEY: envRef("MY_API_KEY"), + }, + }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(1); + context.assignments[0]?.apply("resolved-key-value"); + + const entries = config.plugins?.entries as Record>; + const mcpServers = (entries?.acpx?.config as Record)?.mcpServers as Record< + string, + Record + >; + const env = mcpServers?.mcp1?.env as Record; + expect(env?.API_KEY).toBe("resolved-key-value"); + }); + + it("collects across multiple acpx servers only", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { command: "a", env: { K1: envRef("K1") } }, + s2: { command: "b", env: { K2: envRef("K2"), K3: envRef("K3") } }, + }, + }, + }, + other: { + enabled: true, + config: { + mcpServers: { + s3: { command: "c", env: { K4: envRef("K4") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([ + ["acpx", "bundled"], + ["other", "config"], + ]), + }); + + expect(context.assignments).toHaveLength(3); + const paths = context.assignments.map((a) => a.path).toSorted(); + expect(paths).toEqual([ + "plugins.entries.acpx.config.mcpServers.s1.env.K1", + "plugins.entries.acpx.config.mcpServers.s2.env.K2", + "plugins.entries.acpx.config.mcpServers.s2.env.K3", + ]); + }); + + it("skips entries without config or mcpServers", () => { + const config = asConfig({ + plugins: { + entries: { + noConfig: {}, + noMcpServers: { config: { otherKey: "value" } }, + noEnv: { config: { mcpServers: { s1: { command: "x" } } } }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([]), + }); + + expect(context.assignments).toHaveLength(0); + }); + + it("skips when no plugins.entries at all", () => { + const config = asConfig({}); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([]), + }); + + expect(context.assignments).toHaveLength(0); + }); + + it("skips assignments when plugins.enabled is false", () => { + const config = asConfig({ + plugins: { + enabled: false, + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { command: "node", env: { K: envRef("K") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(0); + expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe( + true, + ); + }); + + it("skips assignments when entry.enabled is false", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: false, + config: { + mcpServers: { + s1: { command: "node", env: { K: envRef("K") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(0); + expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe( + true, + ); + }); + + it("keeps bundled acpx inactive unless explicitly enabled", () => { + const config = asConfig({ + plugins: { + enabled: true, + entries: { + acpx: { + config: { + mcpServers: { + s1: { command: "node", env: { K: envRef("K") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(0); + expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe( + true, + ); + }); + + it("skips assignments when plugin is in denylist", () => { + const config = asConfig({ + plugins: { + deny: ["acpx"], + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { command: "node", env: { K: envRef("K") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(0); + expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe( + true, + ); + }); + + it("skips assignments when allowlist is set and plugin is not in it", () => { + const config = asConfig({ + plugins: { + allow: ["other-plugin"], + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { command: "node", env: { K: envRef("K") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(0); + expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe( + true, + ); + }); + + it("collects assignments when plugin is in allowlist", () => { + const config = asConfig({ + plugins: { + allow: ["acpx"], + entries: { + acpx: { + config: { + mcpServers: { + s1: { command: "node", env: { K: envRef("K") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "config"]]), + }); + + expect(context.assignments).toHaveLength(1); + }); + + it("ignores plain string env values", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { + command: "node", + env: { PLAIN: "hello", ALSO_PLAIN: "world" }, + }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(0); + }); + + it("collects inline env-template refs while leaving normal strings literal", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { + command: "node", + env: { + INLINE: "${INLINE_KEY}", + SECOND: "${SECOND_KEY}", + LITERAL: "hello", + }, + }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]), + }); + + expect(context.assignments).toHaveLength(2); + expect(context.assignments[0]?.path).toBe( + "plugins.entries.acpx.config.mcpServers.s1.env.INLINE", + ); + expect(context.assignments[1]?.path).toBe( + "plugins.entries.acpx.config.mcpServers.s1.env.SECOND", + ); + }); + + it("skips stale acpx entries not in loadablePluginOrigins", () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + s1: { command: "node", env: { K1: envRef("K1") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([]), + }); + + expect(context.assignments).toHaveLength(0); + expect( + context.warnings.some( + (w) => + w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE" && + w.path === "plugins.entries.acpx.config.mcpServers.s1.env.K1", + ), + ).toBe(true); + }); + + it("ignores non-acpx plugin mcpServers surfaces", () => { + const config = asConfig({ + plugins: { + entries: { + other: { + enabled: true, + config: { + mcpServers: { + s1: { command: "node", env: { K1: envRef("K1") } }, + }, + }, + }, + }, + }, + }); + const context = makeContext(config); + + collectPluginConfigAssignments({ + config, + defaults: undefined, + context, + loadablePluginOrigins: loadablePluginOrigins([["other", "config"]]), + }); + + expect(context.assignments).toHaveLength(0); + }); +}); diff --git a/src/secrets/runtime-config-collectors-plugins.ts b/src/secrets/runtime-config-collectors-plugins.ts new file mode 100644 index 00000000000..66d374a3c55 --- /dev/null +++ b/src/secrets/runtime-config-collectors-plugins.ts @@ -0,0 +1,118 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { normalizePluginsConfig, resolveEnableState } from "../plugins/config-state.js"; +import type { PluginOrigin } from "../plugins/types.js"; +import { + collectSecretInputAssignment, + type ResolverContext, + type SecretDefaults, +} from "./runtime-shared.js"; +import { isRecord } from "./shared.js"; + +const ACPX_PLUGIN_ID = "acpx"; + +/** + * Walk plugin config entries and collect SecretRef assignments for MCP server + * env vars. Without this, SecretRefs in paths like + * `plugins.entries.acpx.config.mcpServers.*.env.*` are never resolved and + * remain as raw objects at runtime. + * + * This surface is intentionally scoped to ACPX. Third-party plugins may define + * their own `mcpServers`-shaped config, but that is not a documented SecretRef + * surface and should not be rewritten here. + * + * When `loadablePluginOrigins` is provided, entries whose ID is not in the map + * are treated as inactive (stale config entries for plugins that are no longer + * installed). This prevents resolution failures for SecretRefs belonging to + * non-loadable plugins from blocking startup or preflight validation. + */ +export function collectPluginConfigAssignments(params: { + config: OpenClawConfig; + defaults: SecretDefaults | undefined; + context: ResolverContext; + loadablePluginOrigins?: ReadonlyMap; +}): void { + const entries = params.config.plugins?.entries; + if (!isRecord(entries)) { + return; + } + + const normalizedConfig = normalizePluginsConfig(params.config.plugins); + + for (const [pluginId, entry] of Object.entries(entries)) { + if (pluginId !== ACPX_PLUGIN_ID) { + continue; + } + if (!isRecord(entry)) { + continue; + } + const pluginConfig = entry.config; + if (!isRecord(pluginConfig)) { + continue; + } + + const pluginOrigin = params.loadablePluginOrigins?.get(pluginId); + if (params.loadablePluginOrigins && !pluginOrigin) { + collectMcpServerEnvAssignments({ + pluginId, + pluginConfig, + active: false, + inactiveReason: "plugin is not loadable (stale config entry).", + defaults: params.defaults, + context: params.context, + }); + continue; + } + + const enableState = resolveEnableState(pluginId, pluginOrigin ?? "config", normalizedConfig); + collectMcpServerEnvAssignments({ + pluginId, + pluginConfig, + active: enableState.enabled, + inactiveReason: enableState.reason ?? "plugin is disabled.", + defaults: params.defaults, + context: params.context, + }); + } +} + +function collectMcpServerEnvAssignments(params: { + pluginId: string; + pluginConfig: Record; + active: boolean; + inactiveReason: string; + defaults: SecretDefaults | undefined; + context: ResolverContext; +}): void { + const mcpServers = params.pluginConfig.mcpServers; + if (!isRecord(mcpServers)) { + return; + } + + for (const [serverName, serverConfig] of Object.entries(mcpServers)) { + if (!isRecord(serverConfig)) { + continue; + } + const env = serverConfig.env; + if (!isRecord(env)) { + continue; + } + + for (const [envKey, envValue] of Object.entries(env)) { + // SecretInput allows both explicit objects and inline env-template refs + // like `${MCP_API_KEY}`. Non-ref strings remain untouched because + // collectSecretInputAssignment ignores them. + collectSecretInputAssignment({ + value: envValue, + path: `plugins.entries.${params.pluginId}.config.mcpServers.${serverName}.env.${envKey}`, + expected: "string", + defaults: params.defaults, + context: params.context, + active: params.active, + inactiveReason: `plugin "${params.pluginId}": ${params.inactiveReason}`, + apply: (value) => { + env[envKey] = value; + }, + }); + } + } +} diff --git a/src/secrets/runtime-config-collectors.ts b/src/secrets/runtime-config-collectors.ts index 62cd2e550c8..5fdd15f9c5b 100644 --- a/src/secrets/runtime-config-collectors.ts +++ b/src/secrets/runtime-config-collectors.ts @@ -1,11 +1,14 @@ import type { OpenClawConfig } from "../config/config.js"; +import type { PluginOrigin } from "../plugins/types.js"; import { collectChannelConfigAssignments } from "./runtime-config-collectors-channels.js"; import { collectCoreConfigAssignments } from "./runtime-config-collectors-core.js"; +import { collectPluginConfigAssignments } from "./runtime-config-collectors-plugins.js"; import type { ResolverContext } from "./runtime-shared.js"; export function collectConfigAssignments(params: { config: OpenClawConfig; context: ResolverContext; + loadablePluginOrigins?: ReadonlyMap; }): void { const defaults = params.context.sourceConfig.secrets?.defaults; @@ -20,4 +23,11 @@ export function collectConfigAssignments(params: { defaults, context: params.context, }); + + collectPluginConfigAssignments({ + config: params.config, + defaults, + context: params.context, + loadablePluginOrigins: params.loadablePluginOrigins, + }); } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 149ef92026e..01daa46ecf6 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -2423,6 +2423,165 @@ describe("secrets runtime snapshot", () => { ); }); + it("resolves SecretRef objects for active acpx MCP env vars", async () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + github: { + command: "npx", + env: { + GITHUB_TOKEN: { + source: "env", + provider: "default", + id: "GH_TOKEN_SECRET", + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { + GH_TOKEN_SECRET: "ghp-object-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + const sourceEntries = snapshot.sourceConfig.plugins?.entries as Record< + string, + { config?: Record } + >; + const sourceMcpServers = sourceEntries?.acpx?.config?.mcpServers as Record< + string, + { env?: Record } + >; + const entries = snapshot.config.plugins?.entries as Record< + string, + { config?: Record } + >; + const mcpServers = entries?.acpx?.config?.mcpServers as Record< + string, + { env?: Record } + >; + + expect(mcpServers?.github?.env?.GITHUB_TOKEN).toBe("ghp-object-token"); + expect(sourceMcpServers?.github?.env?.GITHUB_TOKEN).toEqual({ + source: "env", + provider: "default", + id: "GH_TOKEN_SECRET", + }); + }); + + it("resolves inline env-template refs for active acpx MCP env vars", async () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + enabled: true, + config: { + mcpServers: { + github: { + command: "npx", + env: { + GITHUB_TOKEN: "${GH_TOKEN_SECRET}", + SECOND_TOKEN: "${SECOND_SECRET}", + LITERAL: "literal-value", + }, + }, + }, + }, + }, + }, + }, + }); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: { + GH_TOKEN_SECRET: "ghp-inline-token", + SECOND_SECRET: "ghp-second-token", + }, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + const entries = snapshot.config.plugins?.entries as Record< + string, + { config?: Record } + >; + const mcpServers = entries?.acpx?.config?.mcpServers as Record< + string, + { env?: Record } + >; + expect(mcpServers?.github?.env?.GITHUB_TOKEN).toBe("ghp-inline-token"); + expect(mcpServers?.github?.env?.SECOND_TOKEN).toBe("ghp-second-token"); + expect(mcpServers?.github?.env?.LITERAL).toBe("literal-value"); + }); + + it("treats bundled acpx MCP env refs as inactive until the plugin is enabled", async () => { + const config = asConfig({ + plugins: { + entries: { + acpx: { + config: { + mcpServers: { + github: { + command: "npx", + env: { + GITHUB_TOKEN: { + source: "env", + provider: "default", + id: "GH_TOKEN_SECRET", + }, + }, + }, + }, + }, + }, + }, + }, + }); + + const snapshot = await prepareSecretsRuntimeSnapshot({ + config, + env: {}, + agentDirs: ["/tmp/openclaw-agent-main"], + loadAuthStore: () => ({ version: 1, profiles: {} }), + }); + + expect( + snapshot.warnings.some( + (warning) => + warning.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE" && + warning.path === "plugins.entries.acpx.config.mcpServers.github.env.GITHUB_TOKEN", + ), + ).toBe(true); + + const entries = snapshot.config.plugins?.entries as Record< + string, + { config?: Record } + >; + const mcpServers = entries?.acpx?.config?.mcpServers as Record< + string, + { env?: Record } + >; + expect(mcpServers?.github?.env?.GITHUB_TOKEN).toEqual({ + source: "env", + provider: "default", + id: "GH_TOKEN_SECRET", + }); + }); + it("does not write inherited auth stores during runtime secret activation", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-runtime-")); const stateDir = path.join(root, ".openclaw"); diff --git a/src/secrets/runtime.ts b/src/secrets/runtime.ts index 38f71446b41..5300baa19ef 100644 --- a/src/secrets/runtime.ts +++ b/src/secrets/runtime.ts @@ -1,5 +1,10 @@ import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; -import { listAgentIds, resolveAgentDir } from "../agents/agent-scope.js"; +import { + listAgentIds, + resolveAgentDir, + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import type { AuthProfileStore } from "../agents/auth-profiles.js"; import { clearRuntimeAuthProfileStoreSnapshots, @@ -13,6 +18,8 @@ import { type OpenClawConfig, } from "../config/config.js"; import { migrateLegacyConfig } from "../config/legacy-migrate.js"; +import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; +import type { PluginOrigin } from "../plugins/types.js"; import { resolveUserPath } from "../utils.js"; import { collectCommandSecretAssignmentsFromSnapshot, @@ -42,6 +49,7 @@ type SecretsRuntimeRefreshContext = { env: Record; explicitAgentDirs: string[] | null; loadAuthStore: (agentDir?: string) => AuthProfileStore; + loadablePluginOrigins: ReadonlyMap; }; const RUNTIME_PATH_ENV_KEYS = [ @@ -82,6 +90,7 @@ function cloneRefreshContext(context: SecretsRuntimeRefreshContext): SecretsRunt env: { ...context.env }, explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null, loadAuthStore: context.loadAuthStore, + loadablePluginOrigins: new Map(context.loadablePluginOrigins), }; } @@ -116,6 +125,23 @@ function resolveRefreshAgentDirs( return [...new Set([...context.explicitAgentDirs, ...configDerived])]; } +function resolveLoadablePluginOrigins(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): ReadonlyMap { + const workspaceDir = resolveAgentWorkspaceDir( + params.config, + resolveDefaultAgentId(params.config), + ); + const manifestRegistry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir, + cache: true, + env: params.env, + }); + return new Map(manifestRegistry.plugins.map((record) => [record.id, record.origin])); +} + function mergeSecretsRuntimeEnv( env: NodeJS.ProcessEnv | Record | undefined, ): Record { @@ -137,12 +163,17 @@ export async function prepareSecretsRuntimeSnapshot(params: { env?: NodeJS.ProcessEnv; agentDirs?: string[]; loadAuthStore?: (agentDir?: string) => AuthProfileStore; + /** Test override for discovered loadable plugins and their origins. */ + loadablePluginOrigins?: ReadonlyMap; }): Promise { const runtimeEnv = mergeSecretsRuntimeEnv(params.env); const sourceConfig = structuredClone(params.config); const resolvedConfig = structuredClone( migrateLegacyConfig(params.config).config ?? params.config, ); + const loadablePluginOrigins = + params.loadablePluginOrigins ?? + resolveLoadablePluginOrigins({ config: sourceConfig, env: runtimeEnv }); const context = createResolverContext({ sourceConfig, env: runtimeEnv, @@ -151,6 +182,7 @@ export async function prepareSecretsRuntimeSnapshot(params: { collectConfigAssignments({ config: resolvedConfig, context, + loadablePluginOrigins, }); const loadAuthStore = params.loadAuthStore ?? loadAuthProfileStoreForSecretsRuntime; @@ -197,6 +229,7 @@ export async function prepareSecretsRuntimeSnapshot(params: { env: runtimeEnv, explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null, loadAuthStore, + loadablePluginOrigins, }); return snapshot; } @@ -210,6 +243,10 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS env: { ...process.env } as Record, explicitAgentDirs: null, loadAuthStore: loadAuthProfileStoreForSecretsRuntime, + loadablePluginOrigins: resolveLoadablePluginOrigins({ + config: next.sourceConfig, + env: process.env, + }), } satisfies SecretsRuntimeRefreshContext); setRuntimeConfigSnapshot(next.config, next.sourceConfig); replaceRuntimeAuthProfileStoreSnapshots(next.authStores); @@ -225,6 +262,7 @@ export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeS env: activeRefreshContext.env, agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext), loadAuthStore: activeRefreshContext.loadAuthStore, + loadablePluginOrigins: activeRefreshContext.loadablePluginOrigins, }); activateSecretsRuntimeSnapshot(refreshed); return true;