mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 04:11:03 +00:00
fix: resolve acpx MCP secret inputs
This commit is contained in:
@@ -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:
|
||||
|
||||
502
src/secrets/runtime-config-collectors-plugins.test.ts
Normal file
502
src/secrets/runtime-config-collectors-plugins.test.ts
Normal file
@@ -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<string, Record<string, unknown>>;
|
||||
const mcpServers = (entries?.acpx?.config as Record<string, unknown>)?.mcpServers as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const env = mcpServers?.mcp1?.env as Record<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
118
src/secrets/runtime-config-collectors-plugins.ts
Normal file
118
src/secrets/runtime-config-collectors-plugins.ts
Normal file
@@ -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<string, PluginOrigin>;
|
||||
}): 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<string, unknown>;
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, PluginOrigin>;
|
||||
}): 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> }
|
||||
>;
|
||||
const sourceMcpServers = sourceEntries?.acpx?.config?.mcpServers as Record<
|
||||
string,
|
||||
{ env?: Record<string, unknown> }
|
||||
>;
|
||||
const entries = snapshot.config.plugins?.entries as Record<
|
||||
string,
|
||||
{ config?: Record<string, unknown> }
|
||||
>;
|
||||
const mcpServers = entries?.acpx?.config?.mcpServers as Record<
|
||||
string,
|
||||
{ env?: Record<string, unknown> }
|
||||
>;
|
||||
|
||||
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<string, unknown> }
|
||||
>;
|
||||
const mcpServers = entries?.acpx?.config?.mcpServers as Record<
|
||||
string,
|
||||
{ env?: Record<string, unknown> }
|
||||
>;
|
||||
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<string, unknown> }
|
||||
>;
|
||||
const mcpServers = entries?.acpx?.config?.mcpServers as Record<
|
||||
string,
|
||||
{ env?: Record<string, unknown> }
|
||||
>;
|
||||
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");
|
||||
|
||||
@@ -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<string, string | undefined>;
|
||||
explicitAgentDirs: string[] | null;
|
||||
loadAuthStore: (agentDir?: string) => AuthProfileStore;
|
||||
loadablePluginOrigins: ReadonlyMap<string, PluginOrigin>;
|
||||
};
|
||||
|
||||
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<string, PluginOrigin> {
|
||||
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<string, string | undefined> | undefined,
|
||||
): Record<string, string | undefined> {
|
||||
@@ -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<string, PluginOrigin>;
|
||||
}): Promise<PreparedSecretsRuntimeSnapshot> {
|
||||
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<string, string | undefined>,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user