mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-26 16:06:16 +00:00
fix(plugins): move acpx config contracts into manifests
This commit is contained in:
149
src/plugins/config-contracts.ts
Normal file
149
src/plugins/config-contracts.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { findBundledPluginMetadataById } from "./bundled-plugin-metadata.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import type { PluginManifestConfigContracts } from "./manifest.js";
|
||||
import type { PluginOrigin } from "./types.js";
|
||||
|
||||
export type PluginConfigContractMatch = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type PluginConfigContractMetadata = {
|
||||
origin: PluginOrigin;
|
||||
configContracts: PluginManifestConfigContracts;
|
||||
};
|
||||
|
||||
type TraversalState = {
|
||||
segments: string[];
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function normalizePathPattern(pathPattern: string): string[] {
|
||||
return pathPattern
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function appendPathSegment(path: string, segment: string): string {
|
||||
if (!path) {
|
||||
return segment;
|
||||
}
|
||||
return /^\d+$/.test(segment) ? `${path}[${segment}]` : `${path}.${segment}`;
|
||||
}
|
||||
|
||||
export function collectPluginConfigContractMatches(params: {
|
||||
root: unknown;
|
||||
pathPattern: string;
|
||||
}): PluginConfigContractMatch[] {
|
||||
const pattern = normalizePathPattern(params.pathPattern);
|
||||
if (pattern.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let states: TraversalState[] = [{ segments: [], value: params.root }];
|
||||
for (const segment of pattern) {
|
||||
const nextStates: TraversalState[] = [];
|
||||
for (const state of states) {
|
||||
if (segment === "*") {
|
||||
if (Array.isArray(state.value)) {
|
||||
for (const [index, value] of state.value.entries()) {
|
||||
nextStates.push({
|
||||
segments: [...state.segments, String(index)],
|
||||
value,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isRecord(state.value)) {
|
||||
for (const [key, value] of Object.entries(state.value)) {
|
||||
nextStates.push({
|
||||
segments: [...state.segments, key],
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(state.value)) {
|
||||
const index = Number.parseInt(segment, 10);
|
||||
if (Number.isInteger(index) && index >= 0 && index < state.value.length) {
|
||||
nextStates.push({
|
||||
segments: [...state.segments, segment],
|
||||
value: state.value[index],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(state.value) || !Object.prototype.hasOwnProperty.call(state.value, segment)) {
|
||||
continue;
|
||||
}
|
||||
nextStates.push({
|
||||
segments: [...state.segments, segment],
|
||||
value: state.value[segment],
|
||||
});
|
||||
}
|
||||
states = nextStates;
|
||||
if (states.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return states.map((state) => ({
|
||||
path: state.segments.reduce(appendPathSegment, ""),
|
||||
value: state.value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolvePluginConfigContractsById(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
cache?: boolean;
|
||||
pluginIds: readonly string[];
|
||||
}): ReadonlyMap<string, PluginConfigContractMetadata> {
|
||||
const matches = new Map<string, PluginConfigContractMetadata>();
|
||||
const pluginIds = [
|
||||
...new Set(params.pluginIds.map((pluginId) => pluginId.trim()).filter(Boolean)),
|
||||
];
|
||||
if (pluginIds.length === 0) {
|
||||
return matches;
|
||||
}
|
||||
|
||||
const registry = loadPluginManifestRegistry({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
cache: params.cache,
|
||||
});
|
||||
for (const plugin of registry.plugins) {
|
||||
if (!pluginIds.includes(plugin.id)) {
|
||||
continue;
|
||||
}
|
||||
if (!plugin.configContracts) {
|
||||
continue;
|
||||
}
|
||||
matches.set(plugin.id, {
|
||||
origin: plugin.origin,
|
||||
configContracts: plugin.configContracts,
|
||||
});
|
||||
}
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
if (matches.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const bundled = findBundledPluginMetadataById(pluginId);
|
||||
if (!bundled?.manifest.configContracts) {
|
||||
continue;
|
||||
}
|
||||
matches.set(pluginId, {
|
||||
origin: "bundled",
|
||||
configContracts: bundled.manifest.configContracts,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -500,6 +500,35 @@ describe("loadPluginManifestRegistry", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves manifest-owned config contracts from plugin manifests", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, {
|
||||
id: "acpx",
|
||||
configSchema: { type: "object" },
|
||||
configContracts: {
|
||||
dangerousFlags: [{ path: "permissionMode", equals: "approve-all" }],
|
||||
secretInputs: {
|
||||
bundledDefaultEnabled: false,
|
||||
paths: [{ path: "mcpServers.*.env.*", expected: "string" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = loadSingleCandidateRegistry({
|
||||
idHint: "acpx",
|
||||
rootDir: dir,
|
||||
origin: "bundled",
|
||||
});
|
||||
|
||||
expect(registry.plugins[0]?.configContracts).toEqual({
|
||||
dangerousFlags: [{ path: "permissionMode", equals: "approve-all" }],
|
||||
secretInputs: {
|
||||
bundledDefaultEnabled: false,
|
||||
paths: [{ path: "mcpServers.*.env.*", expected: "string" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
it("does not promote legacy top-level capability fields into contracts", () => {
|
||||
const dir = makeTempDir();
|
||||
writeManifest(dir, {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
loadPluginManifest,
|
||||
type OpenClawPackageManifest,
|
||||
type PluginManifestConfigContracts,
|
||||
type PluginManifest,
|
||||
type PluginManifestChannelConfig,
|
||||
type PluginManifestContracts,
|
||||
@@ -86,6 +87,7 @@ export type PluginManifestRecord = {
|
||||
configSchema?: Record<string, unknown>;
|
||||
configUiHints?: Record<string, PluginConfigUiHint>;
|
||||
contracts?: PluginManifestContracts;
|
||||
configContracts?: PluginManifestConfigContracts;
|
||||
channelConfigs?: Record<string, PluginManifestChannelConfig>;
|
||||
channelCatalogMeta?: {
|
||||
id: string;
|
||||
@@ -305,6 +307,7 @@ function buildRecord(params: {
|
||||
configSchema: params.configSchema,
|
||||
configUiHints: params.manifest.uiHints,
|
||||
contracts: params.manifest.contracts,
|
||||
configContracts: params.manifest.configContracts,
|
||||
channelConfigs,
|
||||
...(params.candidate.packageManifest?.channel?.id
|
||||
? {
|
||||
@@ -360,6 +363,7 @@ function buildBundleRecord(params: {
|
||||
schemaCacheKey: undefined,
|
||||
configSchema: undefined,
|
||||
configUiHints: undefined,
|
||||
configContracts: undefined,
|
||||
channelConfigs: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,43 @@ export type PluginManifestModelSupport = {
|
||||
modelPatterns?: string[];
|
||||
};
|
||||
|
||||
export type PluginManifestConfigLiteral = string | number | boolean | null;
|
||||
|
||||
export type PluginManifestDangerousConfigFlag = {
|
||||
/**
|
||||
* Dot-separated config path relative to `plugins.entries.<id>.config`.
|
||||
* Supports `*` wildcards for map/array segments.
|
||||
*/
|
||||
path: string;
|
||||
/** Exact literal that marks this config value as dangerous. */
|
||||
equals: PluginManifestConfigLiteral;
|
||||
};
|
||||
|
||||
export type PluginManifestSecretInputPath = {
|
||||
/**
|
||||
* Dot-separated config path relative to `plugins.entries.<id>.config`.
|
||||
* Supports `*` wildcards for map/array segments.
|
||||
*/
|
||||
path: string;
|
||||
/** Expected resolved type for SecretRef materialization. */
|
||||
expected?: "string";
|
||||
};
|
||||
|
||||
export type PluginManifestSecretInputContracts = {
|
||||
/**
|
||||
* Override bundled-plugin default enablement when deciding whether this
|
||||
* SecretRef surface is active. Use this when the plugin is bundled but the
|
||||
* surface should stay inactive until explicitly enabled in config.
|
||||
*/
|
||||
bundledDefaultEnabled?: boolean;
|
||||
paths: PluginManifestSecretInputPath[];
|
||||
};
|
||||
|
||||
export type PluginManifestConfigContracts = {
|
||||
dangerousFlags?: PluginManifestDangerousConfigFlag[];
|
||||
secretInputs?: PluginManifestSecretInputContracts;
|
||||
};
|
||||
|
||||
export type PluginManifest = {
|
||||
id: string;
|
||||
configSchema: Record<string, unknown>;
|
||||
@@ -65,6 +102,8 @@ export type PluginManifest = {
|
||||
* compat wiring, and contract coverage without importing plugin runtime.
|
||||
*/
|
||||
contracts?: PluginManifestContracts;
|
||||
/** Manifest-owned config behavior consumed by generic core helpers. */
|
||||
configContracts?: PluginManifestConfigContracts;
|
||||
channelConfigs?: Record<string, PluginManifestChannelConfig>;
|
||||
};
|
||||
|
||||
@@ -179,6 +218,88 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u
|
||||
return Object.keys(contracts).length > 0 ? contracts : undefined;
|
||||
}
|
||||
|
||||
function isManifestConfigLiteral(value: unknown): value is PluginManifestConfigLiteral {
|
||||
return (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeManifestDangerousConfigFlags(
|
||||
value: unknown,
|
||||
): PluginManifestDangerousConfigFlag[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: PluginManifestDangerousConfigFlag[] = [];
|
||||
for (const entry of value) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const path = typeof entry.path === "string" ? entry.path.trim() : "";
|
||||
if (!path || !isManifestConfigLiteral(entry.equals)) {
|
||||
continue;
|
||||
}
|
||||
normalized.push({ path, equals: entry.equals });
|
||||
}
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeManifestSecretInputPaths(
|
||||
value: unknown,
|
||||
): PluginManifestSecretInputPath[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: PluginManifestSecretInputPath[] = [];
|
||||
for (const entry of value) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const path = typeof entry.path === "string" ? entry.path.trim() : "";
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
const expected = entry.expected === "string" ? entry.expected : undefined;
|
||||
normalized.push({
|
||||
path,
|
||||
...(expected ? { expected } : {}),
|
||||
});
|
||||
}
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeManifestConfigContracts(
|
||||
value: unknown,
|
||||
): PluginManifestConfigContracts | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const rawSecretInputs = isRecord(value.secretInputs) ? value.secretInputs : undefined;
|
||||
const dangerousFlags = normalizeManifestDangerousConfigFlags(value.dangerousFlags);
|
||||
const secretInputPaths = rawSecretInputs
|
||||
? normalizeManifestSecretInputPaths(rawSecretInputs.paths)
|
||||
: undefined;
|
||||
const secretInputs =
|
||||
secretInputPaths && secretInputPaths.length > 0
|
||||
? ({
|
||||
...(rawSecretInputs?.bundledDefaultEnabled === true
|
||||
? { bundledDefaultEnabled: true }
|
||||
: rawSecretInputs?.bundledDefaultEnabled === false
|
||||
? { bundledDefaultEnabled: false }
|
||||
: {}),
|
||||
paths: secretInputPaths,
|
||||
} satisfies PluginManifestSecretInputContracts)
|
||||
: undefined;
|
||||
const configContracts = {
|
||||
...(dangerousFlags ? { dangerousFlags } : {}),
|
||||
...(secretInputs ? { secretInputs } : {}),
|
||||
} satisfies PluginManifestConfigContracts;
|
||||
return Object.keys(configContracts).length > 0 ? configContracts : undefined;
|
||||
}
|
||||
|
||||
function normalizeManifestModelSupport(value: unknown): PluginManifestModelSupport | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
@@ -379,6 +500,7 @@ export function loadPluginManifest(
|
||||
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
|
||||
const skills = normalizeStringList(raw.skills);
|
||||
const contracts = normalizeManifestContracts(raw.contracts);
|
||||
const configContracts = normalizeManifestConfigContracts(raw.configContracts);
|
||||
const channelConfigs = normalizeChannelConfigs(raw.channelConfigs);
|
||||
|
||||
let uiHints: Record<string, PluginConfigUiHint> | undefined;
|
||||
@@ -408,6 +530,7 @@ export function loadPluginManifest(
|
||||
version,
|
||||
uiHints,
|
||||
contracts,
|
||||
configContracts,
|
||||
channelConfigs,
|
||||
},
|
||||
manifestPath,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginOrigin } from "../plugins/types.js";
|
||||
import { collectPluginConfigAssignments } from "./runtime-config-collectors-plugins.js";
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
type SecretDefaults,
|
||||
} from "./runtime-shared.js";
|
||||
|
||||
const { loadPluginManifestRegistryMock } = vi.hoisted(() => ({
|
||||
loadPluginManifestRegistryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: loadPluginManifestRegistryMock,
|
||||
}));
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
@@ -28,6 +36,27 @@ function loadablePluginOrigins(entries: Array<[string, PluginOrigin]>) {
|
||||
}
|
||||
|
||||
describe("collectPluginConfigAssignments", () => {
|
||||
beforeEach(() => {
|
||||
loadPluginManifestRegistryMock.mockReset();
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "acpx",
|
||||
origin: "bundled",
|
||||
providers: [],
|
||||
legacyPluginIds: [],
|
||||
configContracts: {
|
||||
secretInputs: {
|
||||
bundledDefaultEnabled: false,
|
||||
paths: [{ path: "mcpServers.*.env.*", expected: "string" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("collects SecretRef assignments from active acpx MCP server env vars", () => {
|
||||
const config = asConfig({
|
||||
plugins: {
|
||||
@@ -250,7 +279,7 @@ describe("collectPluginConfigAssignments", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("treats bundled acpx as active by default", () => {
|
||||
it("treats bundled acpx SecretRef surfaces as inactive until enabled", () => {
|
||||
const config = asConfig({
|
||||
plugins: {
|
||||
enabled: true,
|
||||
@@ -274,11 +303,9 @@ describe("collectPluginConfigAssignments", () => {
|
||||
loadablePluginOrigins: loadablePluginOrigins([["acpx", "bundled"]]),
|
||||
});
|
||||
|
||||
expect(context.assignments).toHaveLength(1);
|
||||
expect(context.assignments[0]?.path).toBe("plugins.entries.acpx.config.mcpServers.s1.env.K");
|
||||
expect(context.assignments[0]?.ref.id).toBe("K");
|
||||
expect(context.assignments).toHaveLength(0);
|
||||
expect(context.warnings.some((w) => w.code === "SECRETS_REF_IGNORED_INACTIVE_SURFACE")).toBe(
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -501,4 +528,53 @@ describe("collectPluginConfigAssignments", () => {
|
||||
|
||||
expect(context.assignments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collects manifest-declared SecretRef surfaces for non-acpx plugins", () => {
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "other",
|
||||
origin: "config",
|
||||
providers: [],
|
||||
legacyPluginIds: [],
|
||||
configContracts: {
|
||||
secretInputs: {
|
||||
paths: [{ path: "service.tokens.*", expected: "string" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
const config = asConfig({
|
||||
plugins: {
|
||||
entries: {
|
||||
other: {
|
||||
enabled: true,
|
||||
config: {
|
||||
service: {
|
||||
tokens: {
|
||||
primary: envRef("PRIMARY_TOKEN"),
|
||||
secondary: "${SECONDARY_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const context = makeContext(config);
|
||||
|
||||
collectPluginConfigAssignments({
|
||||
config,
|
||||
defaults: undefined,
|
||||
context,
|
||||
loadablePluginOrigins: loadablePluginOrigins([["other", "config"]]),
|
||||
});
|
||||
|
||||
expect(context.assignments.map((assignment) => assignment.path).toSorted()).toEqual([
|
||||
"plugins.entries.other.config.service.tokens.primary",
|
||||
"plugins.entries.other.config.service.tokens.secondary",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
collectPluginConfigContractMatches,
|
||||
resolvePluginConfigContractsById,
|
||||
} from "../plugins/config-contracts.js";
|
||||
import { normalizePluginsConfig, resolveEnableState } from "../plugins/config-state.js";
|
||||
import type { PluginOrigin } from "../plugins/types.js";
|
||||
import {
|
||||
@@ -8,18 +13,11 @@ import {
|
||||
} from "./runtime-shared.js";
|
||||
import { isRecord } from "./shared.js";
|
||||
|
||||
const ACPX_PLUGIN_ID = "acpx";
|
||||
const ACPX_ENABLED_BY_DEFAULT = false;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Walk manifest-declared plugin config SecretRef surfaces and collect
|
||||
* assignments for runtime materialization. Plugin-owned metadata controls which
|
||||
* config paths support SecretRefs and whether bundled plugins stay inactive on
|
||||
* that surface until explicitly enabled.
|
||||
*
|
||||
* 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
|
||||
@@ -38,9 +36,40 @@ export function collectPluginConfigAssignments(params: {
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizePluginsConfig(params.config.plugins);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
params.config,
|
||||
resolveDefaultAgentId(params.config),
|
||||
);
|
||||
const pluginSecretInputs = new Map(
|
||||
[
|
||||
...resolvePluginConfigContractsById({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
env: params.context.env,
|
||||
cache: true,
|
||||
pluginIds: Object.keys(entries),
|
||||
}).entries(),
|
||||
].flatMap(([pluginId, metadata]) => {
|
||||
const secretInputs = metadata.configContracts.secretInputs;
|
||||
if (!secretInputs?.paths.length) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
[
|
||||
pluginId,
|
||||
{
|
||||
origin: metadata.origin,
|
||||
bundledDefaultEnabled: secretInputs.bundledDefaultEnabled,
|
||||
paths: secretInputs.paths,
|
||||
},
|
||||
] as const,
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [pluginId, entry] of Object.entries(entries)) {
|
||||
if (pluginId !== ACPX_PLUGIN_ID) {
|
||||
const secretInputs = pluginSecretInputs.get(pluginId);
|
||||
if (!secretInputs) {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(entry)) {
|
||||
@@ -53,9 +82,10 @@ export function collectPluginConfigAssignments(params: {
|
||||
|
||||
const pluginOrigin = params.loadablePluginOrigins?.get(pluginId);
|
||||
if (params.loadablePluginOrigins && !pluginOrigin) {
|
||||
collectMcpServerEnvAssignments({
|
||||
collectConfiguredPluginSecretAssignments({
|
||||
pluginId,
|
||||
pluginConfig,
|
||||
secretPaths: secretInputs.paths,
|
||||
active: false,
|
||||
inactiveReason: "plugin is not loadable (stale config entry).",
|
||||
defaults: params.defaults,
|
||||
@@ -64,17 +94,17 @@ export function collectPluginConfigAssignments(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedOrigin = pluginOrigin ?? secretInputs.origin;
|
||||
const enableState = resolveEnableState(
|
||||
pluginId,
|
||||
pluginOrigin ?? "config",
|
||||
resolvedOrigin,
|
||||
normalizedConfig,
|
||||
pluginId === ACPX_PLUGIN_ID && pluginOrigin === "bundled"
|
||||
? ACPX_ENABLED_BY_DEFAULT
|
||||
: undefined,
|
||||
resolvedOrigin === "bundled" ? secretInputs.bundledDefaultEnabled : undefined,
|
||||
);
|
||||
collectMcpServerEnvAssignments({
|
||||
collectConfiguredPluginSecretAssignments({
|
||||
pluginId,
|
||||
pluginConfig,
|
||||
secretPaths: secretInputs.paths,
|
||||
active: enableState.enabled,
|
||||
inactiveReason: enableState.reason ?? "plugin is disabled.",
|
||||
defaults: params.defaults,
|
||||
@@ -83,44 +113,79 @@ export function collectPluginConfigAssignments(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function collectMcpServerEnvAssignments(params: {
|
||||
function collectConfiguredPluginSecretAssignments(params: {
|
||||
pluginId: string;
|
||||
pluginConfig: Record<string, unknown>;
|
||||
secretPaths: ReadonlyArray<{ path: string; expected?: "string" }>;
|
||||
active: boolean;
|
||||
inactiveReason: string;
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const mcpServers = params.pluginConfig.mcpServers;
|
||||
if (!isRecord(mcpServers)) {
|
||||
return;
|
||||
}
|
||||
const seenPaths = new Set<string>();
|
||||
for (const secretPath of params.secretPaths) {
|
||||
for (const match of collectPluginConfigContractMatches({
|
||||
root: params.pluginConfig,
|
||||
pathPattern: secretPath.path,
|
||||
})) {
|
||||
const fullPath = `plugins.entries.${params.pluginId}.config.${match.path}`;
|
||||
if (seenPaths.has(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
seenPaths.add(fullPath);
|
||||
|
||||
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",
|
||||
value: match.value,
|
||||
path: fullPath,
|
||||
expected: secretPath.expected ?? "string",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active: params.active,
|
||||
inactiveReason: `plugin "${params.pluginId}": ${params.inactiveReason}`,
|
||||
apply: (value) => {
|
||||
env[envKey] = value;
|
||||
},
|
||||
apply: createPluginConfigAssignmentApply(params.pluginConfig, match.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginConfigAssignmentApply(
|
||||
pluginConfig: Record<string, unknown>,
|
||||
relativePath: string,
|
||||
): (value: unknown) => void {
|
||||
return (value) => {
|
||||
const segments = relativePath
|
||||
.replace(/\[(\d+)\]/g, ".$1")
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
let current: unknown = pluginConfig;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number.parseInt(segment, 10);
|
||||
current = Number.isInteger(index) ? current[index] : undefined;
|
||||
continue;
|
||||
}
|
||||
current = isRecord(current) ? current[segment] : undefined;
|
||||
}
|
||||
const finalSegment = segments.at(-1);
|
||||
if (!finalSegment) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number.parseInt(finalSegment, 10);
|
||||
if (Number.isInteger(index) && index >= 0 && index < current.length) {
|
||||
current[index] = value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isRecord(current)) {
|
||||
current[finalSegment] = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
81
src/security/dangerous-config-flags.test.ts
Normal file
81
src/security/dangerous-config-flags.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags.js";
|
||||
|
||||
const { loadPluginManifestRegistryMock } = vi.hoisted(() => ({
|
||||
loadPluginManifestRegistryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: loadPluginManifestRegistryMock,
|
||||
}));
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("collectEnabledInsecureOrDangerousFlags", () => {
|
||||
beforeEach(() => {
|
||||
loadPluginManifestRegistryMock.mockReset();
|
||||
});
|
||||
|
||||
it("collects manifest-declared dangerous plugin config values", () => {
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "acpx",
|
||||
configContracts: {
|
||||
dangerousFlags: [{ path: "permissionMode", equals: "approve-all" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
collectEnabledInsecureOrDangerousFlags(
|
||||
asConfig({
|
||||
plugins: {
|
||||
entries: {
|
||||
acpx: {
|
||||
config: {
|
||||
permissionMode: "approve-all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toContain("plugins.entries.acpx.config.permissionMode=approve-all");
|
||||
});
|
||||
|
||||
it("ignores plugin config values that are not declared as dangerous", () => {
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "other",
|
||||
configContracts: {
|
||||
dangerousFlags: [{ path: "mode", equals: "danger" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
collectEnabledInsecureOrDangerousFlags(
|
||||
asConfig({
|
||||
plugins: {
|
||||
entries: {
|
||||
other: {
|
||||
config: {
|
||||
mode: "safe",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
collectPluginConfigContractMatches,
|
||||
resolvePluginConfigContractsById,
|
||||
} from "../plugins/config-contracts.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
|
||||
function formatDangerousConfigFlagValue(value: string | number | boolean | null): string {
|
||||
return value === null ? "null" : String(value);
|
||||
}
|
||||
|
||||
export function collectEnabledInsecureOrDangerousFlags(cfg: OpenClawConfig): string[] {
|
||||
const enabledFlags: string[] = [];
|
||||
@@ -24,8 +34,48 @@ export function collectEnabledInsecureOrDangerousFlags(cfg: OpenClawConfig): str
|
||||
if (cfg.tools?.exec?.applyPatch?.workspaceOnly === false) {
|
||||
enabledFlags.push("tools.exec.applyPatch.workspaceOnly=false");
|
||||
}
|
||||
if (cfg.plugins?.entries?.acpx?.config?.permissionMode === "approve-all") {
|
||||
enabledFlags.push("plugins.entries.acpx.config.permissionMode=approve-all");
|
||||
|
||||
const pluginEntries = cfg.plugins?.entries;
|
||||
if (!isRecord(pluginEntries)) {
|
||||
return enabledFlags;
|
||||
}
|
||||
|
||||
const configContracts = resolvePluginConfigContractsById({
|
||||
config: cfg,
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
|
||||
env: process.env,
|
||||
cache: true,
|
||||
pluginIds: Object.keys(pluginEntries),
|
||||
});
|
||||
const seenFlags = new Set<string>();
|
||||
for (const [pluginId, metadata] of configContracts.entries()) {
|
||||
const dangerousFlags = metadata.configContracts.dangerousFlags;
|
||||
if (!dangerousFlags?.length) {
|
||||
continue;
|
||||
}
|
||||
const pluginEntry = pluginEntries[pluginId];
|
||||
if (!isRecord(pluginEntry) || !isRecord(pluginEntry.config)) {
|
||||
continue;
|
||||
}
|
||||
for (const flag of dangerousFlags) {
|
||||
for (const match of collectPluginConfigContractMatches({
|
||||
root: pluginEntry.config,
|
||||
pathPattern: flag.path,
|
||||
})) {
|
||||
if (!Object.is(match.value, flag.equals)) {
|
||||
continue;
|
||||
}
|
||||
const rendered =
|
||||
`plugins.entries.${pluginId}.config.${match.path}` +
|
||||
`=${formatDangerousConfigFlagValue(flag.equals)}`;
|
||||
if (seenFlags.has(rendered)) {
|
||||
continue;
|
||||
}
|
||||
seenFlags.add(rendered);
|
||||
enabledFlags.push(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enabledFlags;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user