fix(secrets): preserve canonical configuration path identity (#128318)

* fix(secrets): scope manifest-owned credentials

* fix(secrets): isolate plugin owner domains

* fix(secrets): preserve manifest target path identity

* fix(config): preserve typed secret target paths

* fix(secrets): preserve dotted header target paths

* test(secrets): expect canonical auth profile paths

* test(secrets): prove plugin owner authority chain

* fix(secrets): preserve canonical configuration path identity

* docs(plugins): clarify canonical secret paths and route ownership

* fix(cli): keep configuration path parsing out of bootstrap dependencies
This commit is contained in:
Peter Steinberger
2026-08-26 02:52:38 -07:00
committed by GitHub
parent 629bf5424a
commit 22e892a4ea
36 changed files with 1674 additions and 442 deletions
+2
View File
@@ -853,6 +853,8 @@ Each `dangerousFlags` entry supports:
| `bundledDefaultEnabled` | No | `boolean` | 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. |
| `paths` | Yes | `object[]` | Secret-shaped config paths, each with `path` (dot-separated, relative to `plugins.entries.<id>.config`, supports `*` wildcards), optional `expected` (currently only `"string"`), and optional `ownerKind` (currently only `"route"`). A declared owner isolates only that exact matched path when resolution fails; its owner id is the full config path. |
Concrete paths preserve literal record keys and array indices: `headers["X.Trace"]` remains distinct from `headers.X.Trace`, and record key `["0"]` remains distinct from array index `[0]`. Plugin IDs containing dots are quoted the same way, such as `plugins.entries["example.plugin"].config.headers["X.Trace"]`.
## mediaUnderstandingProviderMetadata reference
Use `mediaUnderstandingProviderMetadata` when a media-understanding provider has default models, auto-auth fallback priority, or native document support that generic core helpers need before runtime loads. Keys must also be declared in `contracts.mediaUnderstandingProviders`.
@@ -3,11 +3,20 @@ import { describe, expect, it, vi } from "vitest";
const firecrawlPath = "plugins.entries.firecrawl.config.webFetch.apiKey";
const exaPath = "plugins.entries.exa.config.webSearch.apiKey";
const dottedExaPath = 'plugins.entries["exa.internal"].config.webSearch.apiKey';
vi.mock("../secrets/target-registry.js", () => ({
listSecretTargetRegistryEntries: () => [
{ id: firecrawlPath },
{ id: exaPath },
{ id: firecrawlPath, pathPatternSegments: firecrawlPath.split(".") },
{ id: exaPath, pathPatternSegments: exaPath.split(".") },
{
id: dottedExaPath,
pathPatternSegments: ["plugins", "entries", "exa.internal", "config", "webSearch", "apiKey"],
},
{
id: "plugins.entries.example.config.accounts[].token",
pathPatternSegments: ["plugins", "entries", "example", "config", "accounts[]", "token"],
},
{ id: "plugins.entries.example.config.other.apiKey" },
],
discoverConfigSecretTargetsByIds: (
@@ -21,6 +30,7 @@ vi.mock("../secrets/target-registry.js", () => ({
const ids = new Set(targetIds);
const firecrawlValue = config.plugins?.entries?.firecrawl?.config?.webFetch?.apiKey;
const exaValue = config.plugins?.entries?.exa?.config?.webSearch?.apiKey;
const dottedExaValue = config.plugins?.entries?.["exa.internal"]?.config?.webSearch?.apiKey;
return [
...(ids.has(firecrawlPath) && firecrawlValue !== undefined
? [
@@ -42,11 +52,22 @@ vi.mock("../secrets/target-registry.js", () => ({
},
]
: []),
...(ids.has(dottedExaPath) && dottedExaValue !== undefined
? [
{
entry: { id: dottedExaPath },
path: dottedExaPath,
pathSegments: ["plugins", "entries", "exa.internal", "config", "webSearch", "apiKey"],
value: dottedExaValue,
},
]
: []),
];
},
}));
const { getAgentRuntimeOptionalCommandSecretPaths } = await import("./command-secret-targets.js");
const { getAgentRuntimeCommandSecretTargetIds, getAgentRuntimeOptionalCommandSecretPaths } =
await import("./command-secret-targets.js");
describe("agent runtime command secret targets", () => {
it("marks only configured web SecretRefs optional for generic agent startup", () => {
@@ -65,10 +86,20 @@ describe("agent runtime command secret targets", () => {
webSearch: { apiKey: "inline-key" },
},
},
"exa.internal": {
config: {
webSearch: {
apiKey: { source: "env", provider: "default", id: "INTERNAL_EXA_API_KEY" },
},
},
},
},
},
} as never);
expect(paths).toEqual(new Set([firecrawlPath]));
expect(paths).toEqual(new Set([firecrawlPath, dottedExaPath]));
const runtimeTargetIds = getAgentRuntimeCommandSecretTargetIds();
expect(runtimeTargetIds.has(firecrawlPath)).toBe(true);
expect(runtimeTargetIds.has(dottedExaPath)).toBe(true);
});
});
@@ -47,7 +47,10 @@ describe("command secret targets module import", () => {
it("loads registry lazily for agent runtime plugin credential targets", async () => {
const listSecretTargetRegistryEntries = vi.fn(() => [
{ id: "plugins.entries.example.config.webSearch.apiKey" },
{
id: "plugins.entries.example.config.webSearch.apiKey",
pathPatternSegments: ["plugins", "entries", "example", "config", "webSearch", "apiKey"],
},
{ id: "plugins.entries.example.config.other.apiKey" },
{ id: "channels.telegram.botToken" },
]);
+24 -55
View File
@@ -16,10 +16,12 @@ import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers
import { sortWebSearchProvidersForAutoDetect } from "../plugins/web-search-providers.shared.js";
import { normalizeOptionalAccountId } from "../routing/session-key.js";
import { loadChannelSecretContractApi } from "../secrets/channel-contract-api.js";
import { compileTargetRegistryEntry, matchPathTokens } from "../secrets/target-registry-pattern.js";
import {
discoverConfigSecretTargetsByIds,
listSecretTargetRegistryEntries,
} from "../secrets/target-registry.js";
import { parseConcreteConfigPathTokens } from "../shared/dot-path.js";
const STATIC_QR_REMOTE_TARGET_IDS = ["gateway.remote.token", "gateway.remote.password"] as const;
const STATIC_MODEL_TARGET_IDS = [
@@ -107,36 +109,27 @@ function getChannelSecretTargetIds(): string[] {
return cachedChannelSecretTargetIds;
}
function isPluginWebCredentialTargetId(id: string): boolean {
const segments = id.split(".");
if (segments[0] !== "plugins" || segments[1] !== "entries" || segments[3] !== "config") {
return false;
function pluginWebCredentialConfigPath(entry: {
id: string;
pathPatternSegments?: string[];
}): string | undefined {
const segments = entry.pathPatternSegments;
if (segments?.[0] === "plugins" && segments[1] === "entries" && segments[3] === "config") {
return segments.slice(4).join(".");
}
const configPath = segments.slice(4).join(".");
return configPath === "webSearch.apiKey" || configPath === "webFetch.apiKey";
}
function isPluginWebSearchCredentialTargetId(id: string): boolean {
const segments = id.split(".");
if (segments[0] !== "plugins" || segments[1] !== "entries" || segments[3] !== "config") {
return false;
for (const configPath of ["webSearch.apiKey", "webFetch.apiKey"] as const) {
if (entry.id.startsWith("plugins.entries.") && entry.id.endsWith(`.config.${configPath}`)) {
return configPath;
}
}
return segments.slice(4).join(".") === "webSearch.apiKey";
}
function isPluginWebFetchCredentialTargetId(id: string): boolean {
const segments = id.split(".");
if (segments[0] !== "plugins" || segments[1] !== "entries" || segments[3] !== "config") {
return false;
}
return segments.slice(4).join(".") === "webFetch.apiKey";
return undefined;
}
function getCapabilityWebSearchTargetIds(): string[] {
cachedCapabilityWebSearchTargetIds ??= sortUniqueStrings(
listSecretTargetRegistryEntries()
.map((entry) => entry.id)
.filter(isPluginWebSearchCredentialTargetId),
.filter((entry) => pluginWebCredentialConfigPath(entry) === "webSearch.apiKey")
.map((entry) => entry.id),
);
return cachedCapabilityWebSearchTargetIds;
}
@@ -144,8 +137,8 @@ function getCapabilityWebSearchTargetIds(): string[] {
function getCapabilityWebFetchTargetIds(): string[] {
cachedCapabilityWebFetchTargetIds ??= sortUniqueStrings(
listSecretTargetRegistryEntries()
.map((entry) => entry.id)
.filter(isPluginWebFetchCredentialTargetId),
.filter((entry) => pluginWebCredentialConfigPath(entry) === "webFetch.apiKey")
.map((entry) => entry.id),
);
return cachedCapabilityWebFetchTargetIds;
}
@@ -171,38 +164,11 @@ function resolveSearchConfig(config: OpenClawConfig): Record<string, unknown> |
: undefined;
}
function pathPatternMatchesConcretePath(pathPattern: string, path: string): boolean {
const pathSegments = path.split(".");
const patternSegments = pathPattern.split(".");
let pathIndex = 0;
for (const segment of patternSegments) {
if (segment === "*") {
if (!pathSegments[pathIndex]) {
return false;
}
pathIndex += 1;
continue;
}
if (segment.endsWith("[]")) {
const field = segment.slice(0, -2);
if (pathSegments[pathIndex] !== field || !/^\d+$/.test(pathSegments[pathIndex + 1] ?? "")) {
return false;
}
pathIndex += 2;
continue;
}
if (pathSegments[pathIndex] !== segment) {
return false;
}
pathIndex += 1;
}
return pathIndex === pathSegments.length;
}
// Registry entries use wildcard path patterns; command inputs often identify one concrete config path.
function targetIdsForConfigPath(path: string): string[] {
const pathSegments = parseConcreteConfigPathTokens(path);
return listSecretTargetRegistryEntries()
.filter((entry) => pathPatternMatchesConcretePath(entry.pathPattern ?? entry.id, path))
.filter((entry) => matchPathTokens(pathSegments, compileTargetRegistryEntry(entry).pathTokens))
.map((entry) => entry.id)
.toSorted();
}
@@ -621,8 +587,11 @@ function getAgentRuntimeBaseTargetIds(): string[] {
cachedAgentRuntimeBaseTargetIds ??= [
...STATIC_AGENT_RUNTIME_BASE_TARGET_IDS,
...listSecretTargetRegistryEntries()
.filter((entry) => {
const configPath = pluginWebCredentialConfigPath(entry);
return configPath === "webSearch.apiKey" || configPath === "webFetch.apiKey";
})
.map((entry) => entry.id)
.filter(isPluginWebCredentialTargetId)
.toSorted(),
];
return cachedAgentRuntimeBaseTargetIds;
+70 -12
View File
@@ -20,9 +20,14 @@ import {
validateExecSecretRefId,
} from "../secrets/ref-contract.js";
import { resolveConfigSecretTargetByPath } from "../secrets/target-registry.js";
import { toDotPath } from "../shared/dot-path.js";
import {
parseConcreteConfigPathWithProvenance,
toDotPath,
type ConcreteConfigPathSegment,
} from "../shared/dot-path.js";
import { formatCliCommand } from "./command-format.js";
import {
formatConfigSetPath,
parseConfigSetPath,
parseConfigSetValue,
type PathSegment,
@@ -44,6 +49,8 @@ const CONFIG_PATCH_STDIN_MAX_BYTES = 1024 * 1024;
export type ConfigSetOperation = {
inputMode: ConfigSetDryRunInputMode;
requestedPath: PathSegment[];
pathTokens?: readonly ConcreteConfigPathSegment[];
quotedNumericSegments?: ReadonlySet<number>;
setPath: PathSegment[];
value: unknown;
mutation?: "set" | "merge" | "replace" | "delete";
@@ -315,18 +322,24 @@ function touchesSecretDefaults(path: PathSegment[]): boolean {
function buildRefAssignmentOperation(params: {
requestedPath: PathSegment[];
pathTokens?: readonly ConcreteConfigPathSegment[];
quotedNumericSegments?: ReadonlySet<number>;
ref: SecretRef;
inputMode: ConfigSetDryRunInputMode;
}): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
const resolved = resolveConfigSecretTargetByPath(params.requestedPath, params.pathTokens);
if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) {
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
...(params.pathTokens ? { pathTokens: params.pathTokens } : {}),
...(params.quotedNumericSegments
? { quotedNumericSegments: params.quotedNumericSegments }
: {}),
setPath: resolved.refPathSegments,
value: params.ref,
schemaValidated: true,
touchedSecretTargetPath: toDotPath(resolved.pathSegments),
touchedSecretTargetPath: formatConfigSetPath(resolved.pathSegments, params.pathTokens),
assignedRef: params.ref,
...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
};
@@ -334,10 +347,17 @@ function buildRefAssignmentOperation(params: {
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
...(params.pathTokens ? { pathTokens: params.pathTokens } : {}),
...(params.quotedNumericSegments
? { quotedNumericSegments: params.quotedNumericSegments }
: {}),
setPath: params.requestedPath,
value: params.ref,
...(resolved ? { schemaValidated: true } : {}),
touchedSecretTargetPath: toDotPath(resolved?.pathSegments ?? params.requestedPath),
touchedSecretTargetPath: formatConfigSetPath(
resolved?.pathSegments ?? params.requestedPath,
params.pathTokens,
),
assignedRef: params.ref,
...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
};
@@ -345,18 +365,29 @@ function buildRefAssignmentOperation(params: {
function buildValueAssignmentOperation(params: {
requestedPath: PathSegment[];
pathTokens?: readonly ConcreteConfigPathSegment[];
quotedNumericSegments?: ReadonlySet<number>;
value: unknown;
inputMode: ConfigSetDryRunInputMode;
}): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
const resolved = resolveConfigSecretTargetByPath(params.requestedPath, params.pathTokens);
const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath);
const coercedRef = coerceSecretRef(params.value);
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: params.requestedPath,
...(params.pathTokens ? { pathTokens: params.pathTokens } : {}),
...(params.quotedNumericSegments
? { quotedNumericSegments: params.quotedNumericSegments }
: {}),
setPath:
coercedRef && resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments
? resolved.refPathSegments
: params.requestedPath,
value: params.value,
...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}),
...(resolved
? { touchedSecretTargetPath: formatConfigSetPath(resolved.pathSegments, params.pathTokens) }
: {}),
...(providerAlias ? { touchedProviderAlias: providerAlias } : {}),
...(coercedRef ? { assignedRef: coercedRef } : {}),
};
@@ -364,10 +395,15 @@ function buildValueAssignmentOperation(params: {
function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] {
return entries.map((entry, index) => {
const path = parseConfigSetPath(entry.path);
const { tokens: pathTokens, quotedNumericSegments } = parseConcreteConfigPathWithProvenance(
entry.path,
);
const path = pathTokens.map(String);
if (entry.ref !== undefined) {
return buildRefAssignmentOperation({
requestedPath: path,
pathTokens,
quotedNumericSegments,
ref: parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`),
inputMode: "json",
});
@@ -384,6 +420,8 @@ function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperatio
return {
inputMode: "json",
requestedPath: path,
pathTokens,
quotedNumericSegments,
setPath: path,
value: validated.data,
schemaValidated: true,
@@ -392,6 +430,8 @@ function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperatio
}
return buildValueAssignmentOperation({
requestedPath: path,
pathTokens,
quotedNumericSegments,
value: entry.value,
inputMode: "json",
});
@@ -404,7 +444,11 @@ function buildSingleSetOperations(params: {
opts: ConfigSetOptions;
}): ConfigSetOperation[] {
const pathProvided = typeof params.path === "string" && params.path.trim().length > 0;
const parsedPath = pathProvided ? parseConfigSetPath(params.path as string) : null;
const parsedConcretePath = pathProvided
? parseConcreteConfigPathWithProvenance(params.path as string)
: null;
const pathTokens = parsedConcretePath?.tokens ?? null;
const parsedPath = pathTokens?.map(String) ?? null;
const strictJson = Boolean(params.opts.strictJson || params.opts.json);
const modeResolution = resolveConfigSetMode({
hasBatchMode: false,
@@ -431,6 +475,8 @@ function buildSingleSetOperations(params: {
return [
buildRefAssignmentOperation({
requestedPath: parsedPath,
pathTokens: pathTokens ?? undefined,
quotedNumericSegments: parsedConcretePath?.quotedNumericSegments,
ref: parseSecretRefBuilder({
provider: params.opts.refProvider,
source: params.opts.refSource,
@@ -453,6 +499,10 @@ function buildSingleSetOperations(params: {
{
inputMode: "builder",
requestedPath: parsedPath,
...(pathTokens ? { pathTokens } : {}),
...(parsedConcretePath
? { quotedNumericSegments: parsedConcretePath.quotedNumericSegments }
: {}),
setPath: parsedPath,
value: buildProviderFromBuilder(params.opts),
schemaValidated: true,
@@ -470,6 +520,8 @@ function buildSingleSetOperations(params: {
return [
buildValueAssignmentOperation({
requestedPath: parsedPath,
pathTokens: pathTokens ?? undefined,
quotedNumericSegments: parsedConcretePath?.quotedNumericSegments,
value: parseConfigSetValue(params.value, strictJson),
inputMode: modeResolution.mode === "json" ? "json" : "value",
}),
@@ -537,19 +589,25 @@ function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation {
};
}
export function buildUnsetOperation(path: PathSegment[]): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(path);
export function buildUnsetOperation(
path: PathSegment[],
pathTokens?: readonly ConcreteConfigPathSegment[],
): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(path, pathTokens);
const providerAlias = parseProviderAliasFromTargetPath(path);
return {
inputMode: "unset",
requestedPath: path,
...(pathTokens ? { pathTokens } : {}),
setPath: path,
value: undefined,
mutation: "delete",
...(touchesSecretProviderCollection(path) || touchesSecretDefaults(path)
? { touchesAllSecretRefs: true }
: {}),
...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}),
...(resolved
? { touchedSecretTargetPath: formatConfigSetPath(resolved.pathSegments, pathTokens) }
: {}),
...(providerAlias ? { touchedProviderAlias: providerAlias } : {}),
};
}
+24 -128
View File
@@ -2,13 +2,27 @@ import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-c
import JSON5 from "json5";
import { rejectConfigNonFiniteNumbers } from "../config/io.read-helpers.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { toDotPath } from "../shared/dot-path.js";
import {
formatConcreteConfigPath,
toDotPath,
type ConcreteConfigPathSegment,
} from "../shared/dot-path.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { formatCliCommand } from "./command-format.js";
import { formatStrictJsonParseFailure } from "./error-format.js";
export { parseConcreteConfigPath as parseConfigSetPath } from "../shared/dot-path.js";
export type PathSegment = string;
export function formatConfigSetPath(
path: readonly PathSegment[],
pathTokens?: readonly ConcreteConfigPathSegment[],
source?: unknown,
): string {
return formatConcreteConfigPath(pathTokens ?? path, source);
}
export type JsonSchemaRecord = {
type?: unknown;
properties?: unknown;
@@ -21,6 +35,8 @@ export type JsonSchemaRecord = {
type SetAtPathOptions = {
numericObjectKeys?: boolean;
pathTokens?: readonly ConcreteConfigPathSegment[];
quotedNumericSegments?: ReadonlySet<number>;
schema?: JsonSchemaRecord;
};
@@ -32,133 +48,6 @@ function isIndexSegment(raw: string): boolean {
return parseIndexSegment(raw) !== undefined;
}
function parseBracketPathSegment(raw: string, fullPath: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error(`Invalid path (empty "[]"): ${fullPath}`);
}
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
try {
const parsed = JSON5.parse(trimmed) as unknown;
if (typeof parsed === "string" && parsed.trim()) {
return parsed;
}
} catch (err) {
throw new Error(`Invalid path bracket string (${trimmed}): ${fullPath}`, { cause: err });
}
throw new Error(`Invalid path bracket string (${trimmed}): ${fullPath}`);
}
return trimmed;
}
function assertNotWhitespaceSegment(current: string, raw: string): void {
if (current.length > 0 && !current.trim()) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
}
function findBracketPathClose(path: string, open: number): number {
let quote: '"' | "'" | undefined;
for (let index = open + 1; index < path.length; index += 1) {
const character = path[index];
if (quote) {
if (character === "\\") {
index += 1;
} else if (character === quote) {
quote = undefined;
}
continue;
}
if (character === "]") {
return index;
}
if ((character === '"' || character === "'") && !path.slice(open + 1, index).trim()) {
quote = character;
}
}
return -1;
}
function parsePath(raw: string): PathSegment[] {
const trimmed = raw.trim();
if (!trimmed) {
return [];
}
const parts: string[] = [];
let current = "";
let segmentEmitted = false;
let i = 0;
while (i < trimmed.length) {
const ch = trimmed[i];
if (ch === "\\") {
const next = trimmed[i + 1];
if (next === undefined) {
throw new Error(`Invalid path (trailing escape): ${raw}`);
}
current += next;
i += 2;
continue;
}
if (ch === ".") {
assertNotWhitespaceSegment(current, raw);
if (!segmentEmitted && !current.trim()) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current.trim());
}
current = "";
segmentEmitted = false;
i += 1;
continue;
}
if (ch === "[") {
assertNotWhitespaceSegment(current, raw);
if (!current.trim() && !segmentEmitted && parts.length > 0) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current.trim());
}
current = "";
const close = findBracketPathClose(trimmed, i);
if (close === -1) {
throw new Error(`Invalid path (missing "]"): ${raw}`);
}
const inside = trimmed.slice(i + 1, close).trim();
if (!inside) {
throw new Error(`Invalid path (empty "[]"): ${raw}`);
}
parts.push(parseBracketPathSegment(inside, raw));
const next = trimmed[close + 1];
if (next !== undefined && next !== "." && next !== "[") {
throw new Error(`Invalid path (missing separator after bracket): ${raw}`);
}
segmentEmitted = true;
i = close + 1;
continue;
}
current += ch;
i += 1;
}
if (!segmentEmitted && !current.trim()) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current.trim());
}
return parts;
}
export function parseConfigSetPath(path: string): string[] {
const parsedPath = parsePath(path);
if (parsedPath.length === 0) {
throw new Error("Path is empty.");
}
validatePathSegments(parsedPath);
return parsedPath;
}
export function parseConfigSetValue(raw: string, strictJson: boolean): unknown {
const trimmed = raw.trim();
if (strictJson) {
@@ -364,6 +253,13 @@ function shouldCreateArrayForMissingPathSegment(params: {
if (!params.next || params.options?.numericObjectKeys || !isIndexSegment(params.next)) {
return false;
}
const nextToken = params.options?.pathTokens?.[params.segmentIndex + 1];
if (typeof nextToken === "number") {
return true;
}
if (params.options?.quotedNumericSegments?.has(params.segmentIndex + 1)) {
return false;
}
const parentPath = params.path.slice(0, params.segmentIndex + 1);
return schemaPrefersArrayAtPath(params.options?.schema, parentPath) ?? true;
}
+15 -1
View File
@@ -29,6 +29,7 @@ import {
} from "./config-cli-model-normalization.js";
import {
assertNonDestructiveReplacement,
formatConfigSetPath,
getAtPath,
mergeAtPath,
setAtPath,
@@ -316,6 +317,10 @@ export async function runConfigOperations(params: {
if (operation.mutation === "merge" || (options.merge && operation.mutation !== "replace")) {
mergeAtPath(next, operation.setPath, operation.value, {
numericObjectKeys: params.successMode === "patch",
...(operation.pathTokens ? { pathTokens: operation.pathTokens } : {}),
...(operation.quotedNumericSegments
? { quotedNumericSegments: operation.quotedNumericSegments }
: {}),
schema: mutationSchema,
});
} else {
@@ -327,6 +332,10 @@ export async function runConfigOperations(params: {
});
setAtPath(next, operation.setPath, operation.value, {
numericObjectKeys: params.successMode === "patch",
...(operation.pathTokens ? { pathTokens: operation.pathTokens } : {}),
...(operation.quotedNumericSegments
? { quotedNumericSegments: operation.quotedNumericSegments }
: {}),
schema: mutationSchema,
});
}
@@ -501,7 +510,12 @@ export async function runConfigOperations(params: {
if (params.successMode === "set" && operations.length === 1) {
const operation = operations[0];
const action = operation?.mutation === "delete" ? "Removed" : "Updated";
runtime.log(info(`${action} ${toDotPath(operation?.requestedPath ?? [])}. ${hint}`));
const requestedPath = formatConfigSetPath(
operation?.requestedPath ?? [],
operation?.pathTokens,
nextConfig,
);
runtime.log(info(`${action} ${requestedPath}. ${hint}`));
} else if (params.successMode === "set") {
runtime.log(info(`Updated ${operations.length} config paths. ${hint}`));
} else {
+177 -31
View File
@@ -45,20 +45,38 @@ const mockLoadChannelSecretContractApi = vi.hoisted(() =>
telegram: ["botToken"],
};
return {
secretTargetRegistryEntries: (fields[channelId] ?? []).map((field) => {
const pathPattern = `channels.${channelId}.${field}`;
return {
id: pathPattern,
targetType: pathPattern,
configFile: "openclaw.json" as const,
pathPattern,
secretShape: "secret_input" as const,
expectedResolvedValue: "string" as const,
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
};
}),
secretTargetRegistryEntries: [
...(fields[channelId] ?? []).map((field) => {
const pathPattern = `channels.${channelId}.${field}`;
return {
id: pathPattern,
targetType: pathPattern,
configFile: "openclaw.json" as const,
pathPattern,
secretShape: "secret_input" as const,
expectedResolvedValue: "string" as const,
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
};
}),
...(channelId === "discord"
? [
{
id: "channels.discord.accounts[].token",
targetType: "channels.discord.accounts[].token",
configFile: "openclaw.json" as const,
pathPattern: "channels.discord.accounts[].token",
refPathPattern: "channels.discord.accounts[].tokenRef",
secretShape: "sibling_ref" as const,
expectedResolvedValue: "string" as const,
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
]
: []),
],
};
}),
);
@@ -1908,6 +1926,114 @@ describe("config cli", () => {
});
});
it.each(["ref builder", "JSON value", "batch ref", "batch value"] as const)(
"writes array-indexed sibling SecretRefs to their registered ref path in %s mode",
async (mode) => {
const resolved = {
channels: { discord: { accounts: [{ token: "existing-token" }] } },
} as unknown as OpenClawConfig;
const ref = { source: "env", provider: "default", id: "DISCORD_ACCOUNT_TOKEN" };
const configPath = "channels.discord.accounts[0].token";
setSnapshot(resolved, resolved);
const args =
mode === "ref builder"
? [
configPath,
"--ref-provider",
ref.provider,
"--ref-source",
ref.source,
"--ref-id",
ref.id,
]
: mode === "JSON value"
? [configPath, JSON.stringify(ref), "--strict-json"]
: [
"--batch-json",
JSON.stringify([
mode === "batch ref"
? { path: configPath, ref }
: { path: configPath, value: ref },
]),
];
await runConfigSet(...args);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig() as {
channels?: { discord?: { accounts?: Array<{ token?: unknown; tokenRef?: unknown }> } };
};
expect(written.channels?.discord?.accounts?.[0]).toEqual({
token: "existing-token",
tokenRef: ref,
});
expect(requireWriteOptions().explicitSetPaths).toEqual([
["channels", "discord", "accounts", "0", "tokenRef"],
]);
},
);
it("keeps a quoted numeric record key distinct from an array-indexed secret target", async () => {
const resolved = {
channels: { discord: { accounts: { "0": { token: "existing-token" } } } },
} as unknown as OpenClawConfig;
const ref = { source: "env", provider: "default", id: "DISCORD_ACCOUNT_TOKEN" };
setSnapshot(resolved, resolved);
await runConfigSet(
'channels.discord.accounts["0"].token',
"--ref-provider",
ref.provider,
"--ref-source",
ref.source,
"--ref-id",
ref.id,
);
const written = firstWrittenConfig() as {
channels?: { discord?: { accounts?: Record<string, { token?: unknown }> } };
};
expect(written.channels?.discord?.accounts?.["0"]).toEqual({ token: ref });
expect(requireWriteOptions().explicitSetPaths).toEqual([
["channels", "discord", "accounts", "0", "token"],
]);
});
it.each([
[
'agents.defaults.models["fixture/model.v1"].params["literal.dot"]',
"LITERAL",
{ "literal.dot": "LITERAL" },
],
[
'agents.defaults.models["fixture/model.v1"].params.literal.dot',
"NESTED",
{ literal: { dot: "NESTED" } },
],
[
'agents.defaults.models["fixture/model.v1"].params.record["0"]',
"RECORD-ZERO",
{ record: { "0": "RECORD-ZERO" } },
],
[
'agents.defaults.models["fixture/model.v1"].params.list[0]',
"ARRAY-ZERO",
{ list: ["ARRAY-ZERO"] },
],
])("preserves generic config path identity for %s", async (configPath, value, expected) => {
const resolved = {
agents: { defaults: { models: { "fixture/model.v1": { params: {} } } } },
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigSet(configPath, JSON.stringify(value), "--strict-json");
expect(firstWrittenConfig().agents?.defaults?.models?.["fixture/model.v1"]?.params).toEqual(
expected,
);
expectLogIncludes(`Updated ${configPath}`);
});
it("keeps numeric config set path segments as object keys for schema-backed Discord guild records", async () => {
setConfigMutationShapeSchema();
const resolved: OpenClawConfig = {
@@ -3771,6 +3897,11 @@ describe("config cli", () => {
args: ["config", "set", "gateway.[port]", "23456"],
error: "Invalid path (empty segment): gateway.[port]",
},
{
name: "rejects registry array patterns as concrete config paths",
args: ["config", "get", "plugins.entries.example.config.accounts[].token"],
error: 'Invalid path (empty "[]"): plugins.entries.example.config.accounts[].token',
},
{
name: "rejects a trailing escape for config get before reading another key",
args: ["config", "get", "gateway.port\\"],
@@ -3855,6 +3986,14 @@ describe("config cli", () => {
["agents.list[0].id", ["agents", "list", "0", "id"]],
["agents.list[0][1]", ["agents", "list", "0", "1"]],
["[0]", ["0"]],
[
'plugins.entries.example.config.accounts["0"].token',
["plugins", "entries", "example", "config", "accounts", "0", "token"],
],
[
'plugins.entries["foo.config.bar"].config.token',
["plugins", "entries", "foo.config.bar", "config", "token"],
],
[" gateway.port ", ["gateway", "port"]],
["channels.discord.guilds.prod\\.guild", ["channels", "discord", "guilds", "prod.guild"]],
[
@@ -4494,7 +4633,7 @@ describe("config cli", () => {
"--strict-json",
]);
expectLogIncludes("Updated agents.list.1.model.primary");
expectLogIncludes("Updated agents.list[1].model.primary");
expectLogIncludes("Change will apply without restarting the gateway.");
expectLogExcludes("Restart the gateway to apply.");
});
@@ -4544,7 +4683,7 @@ describe("config cli", () => {
"--strict-json",
]);
expectLogIncludes("Updated agents.list.0.model.primary");
expectLogIncludes("Updated agents.list[0].model.primary");
expectLogIncludes("Restart the gateway to apply.");
expectLogExcludes("Change will apply without restarting the gateway.");
});
@@ -4568,7 +4707,7 @@ describe("config cli", () => {
"--strict-json",
]);
expectLogIncludes("Updated agents.list.0.model.primary");
expectLogIncludes("Updated agents.list[0].model.primary");
expectLogIncludes("Change will apply without restarting the gateway.");
expectLogExcludes("Restart the gateway to apply.");
});
@@ -4674,23 +4813,30 @@ describe("config cli", () => {
expectLogExcludes("Change will apply without restarting the gateway.");
});
it("keeps plugin entry config writes restart-backed when reload metadata is absent", async () => {
const resolved: OpenClawConfig = {
plugins: {
entries: {
canvas: { enabled: true },
it.each([
["canvas", "plugins.entries.canvas.enabled"],
["canvas.internal", 'plugins.entries["canvas.internal"].enabled'],
["canvas", "plugins.entries.canvas.config.accounts[0].enabled"],
])(
"keeps plugin entry %s writes unambiguous and restart-backed",
async (pluginId, configPath) => {
const resolved = {
plugins: {
entries: {
[pluginId]: { enabled: true, config: { accounts: [{ enabled: true }] } },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigSet("plugins.entries.canvas.enabled", "false");
await runConfigSet(configPath, "false");
expectLogIncludes("Updated plugins.entries.canvas.enabled");
expectLogIncludes("Restart the gateway to apply.");
expectLogExcludes("Change will apply without restarting the gateway.");
expectLogExcludes("No gateway restart needed.");
});
expectLogIncludes(`Updated ${configPath}`);
expectLogIncludes("Restart the gateway to apply.");
expectLogExcludes("Change will apply without restarting the gateway.");
expectLogExcludes("No gateway restart needed.");
},
);
it("keeps the restart hint for mixed hot and restart batch updates", async () => {
const resolved: OpenClawConfig = {
+4 -2
View File
@@ -22,6 +22,7 @@ import {
writeRuntimeJson,
writeRuntimeStdout,
} from "../runtime.js";
import { parseConcreteConfigPathTokens } from "../shared/dot-path.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import {
@@ -229,7 +230,8 @@ export async function runConfigUnset(opts: {
if (cliOptions.json && !cliOptions.dryRun) {
throw new Error("--json can only be used with --dry-run.");
}
const parsedPath = parseConfigSetPath(opts.path);
const pathTokens = parseConcreteConfigPathTokens(opts.path);
const parsedPath = pathTokens.map(String);
assertConfigPathIsNotAutoManaged(parsedPath);
const mutationStart = cliOptions.dryRun
? { snapshot: await loadValidConfig(runtime), writeOptions: {} }
@@ -276,7 +278,7 @@ export async function runConfigUnset(opts: {
runtime.exit(1);
return;
}
const operation = buildUnsetOperation(parsedPath);
const operation = buildUnsetOperation(parsedPath, pathTokens);
if (cliOptions.dryRun) {
await runConfigOperations({
runtime,
+115
View File
@@ -6,6 +6,11 @@ import {
containsEnvVarReference,
resolveConfigEnvVars,
} from "./env-substitution.js";
import {
createConfigResolutionFacts,
getAuthoredConfigSecretRef,
setConfigResolutionFacts,
} from "./resolution-facts.js";
type SubstitutionScenario = {
name: string;
@@ -138,6 +143,65 @@ describe("resolveConfigEnvVars", () => {
varName: "MISSING",
configPath: "items[1]",
},
{
name: "dotted plugin ID remains one record key",
config: { plugins: { entries: { "foo.config.bar": { token: "${MISSING}" } } } },
env: {},
varName: "MISSING",
configPath: 'plugins.entries["foo.config.bar"].token',
},
{
name: "dotted header remains one record key",
config: {
plugins: { entries: { fixture: { config: { headers: { "X.Trace": "${MISSING}" } } } } },
},
env: {},
varName: "MISSING",
configPath: 'plugins.entries.fixture.config.headers["X.Trace"]',
},
{
name: "nested header segments remain dotted",
config: {
plugins: {
entries: { fixture: { config: { headers: { X: { Trace: "${MISSING}" } } } } },
},
},
env: {},
varName: "MISSING",
configPath: "plugins.entries.fixture.config.headers.X.Trace",
},
{
name: "numeric-looking record key is not an array index",
config: {
plugins: { entries: { fixture: { config: { headers: { "0": "${MISSING}" } } } } },
},
env: {},
varName: "MISSING",
configPath: 'plugins.entries.fixture.config.headers["0"]',
},
{
name: "existing non-plugin root record paths stay unchanged",
config: { "root.key": "${MISSING}" },
env: {},
varName: "MISSING",
configPath: "root.key",
},
{
name: "plugin config array indices remain canonical",
config: {
plugins: { entries: { fixture: { config: { headers: ["${MISSING}"] } } } },
},
env: {},
varName: "MISSING",
configPath: "plugins.entries.fixture.config.headers[0]",
},
{
name: "hyphenated record key keeps its existing dotted spelling",
config: { providers: { "vercel-gateway": { apiKey: "${MISSING}" } } },
env: {},
varName: "MISSING",
configPath: "providers.vercel-gateway.apiKey",
},
{
name: "empty string env value treated as missing",
config: { key: "${EMPTY}" },
@@ -272,6 +336,57 @@ describe("resolveConfigEnvVars", () => {
});
describe("graceful missing env var handling (onMissing)", () => {
it("keeps authored SecretRef provenance distinct across dotted plugin and header keys", () => {
const pendingEnvSecretRefs = new Map<string, string>();
const config = resolveConfigEnvVars(
{
plugins: {
entries: {
"foo.config.bar": { config: { token: "$ATTACKER" } },
foo: {
config: {
bar: { config: { token: "$VICTIM" } },
headers: {
"X.Trace": "$DOTTED_HEADER",
X: { Trace: "$NESTED_HEADER" },
},
},
},
},
},
models: {
providers: {
"alpha:beta": {
apiKey: "$CORE_PROVIDER",
headers: { "X.Trace": "$CORE_HEADER" },
},
},
},
},
{},
{
onPendingEnvSecretRef: (id, configPath) => pendingEnvSecretRefs.set(configPath, id),
},
);
setConfigResolutionFacts(config, createConfigResolutionFacts([], pendingEnvSecretRefs));
expect([...pendingEnvSecretRefs]).toEqual([
['plugins.entries["foo.config.bar"].config.token', "ATTACKER"],
["plugins.entries.foo.config.bar.config.token", "VICTIM"],
['plugins.entries.foo.config.headers["X.Trace"]', "DOTTED_HEADER"],
["plugins.entries.foo.config.headers.X.Trace", "NESTED_HEADER"],
["models.providers.alpha:beta.apiKey", "CORE_PROVIDER"],
["models.providers.alpha:beta.headers.X.Trace", "CORE_HEADER"],
]);
for (const [configPath, id] of pendingEnvSecretRefs) {
expect(getAuthoredConfigSecretRef(config, configPath), configPath).toEqual({
source: "env",
provider: "default",
id,
});
}
});
it("collects warnings and preserves placeholder when onMissing is set", () => {
const warnings: EnvSubstitutionWarning[] = [];
const result = resolveConfigEnvVars(
+10 -1
View File
@@ -22,6 +22,7 @@
// Pattern for valid uppercase env var names: starts with letter or underscore,
// followed by letters, numbers, or underscores (all uppercase)
import { appendConfigPathSegment } from "../shared/dot-path.js";
import { isPlainObject } from "../utils.js";
import { parseEnvTemplateSecretRef } from "./types.secrets.js";
@@ -189,7 +190,15 @@ function substituteAny(
if (isPlainObject(value)) {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const childPath = path ? `${path}.${key}` : key;
const isPluginConfigPath =
path === "plugins.entries" ||
path.startsWith("plugins.entries.") ||
path.startsWith("plugins.entries[");
const childPath = isPluginConfigPath
? appendConfigPathSegment(path, key)
: path
? `${path}.${key}`
: key;
result[key] = substituteAny(val, env, childPath, opts);
}
return result;
+15 -11
View File
@@ -1,5 +1,6 @@
// Matches plugin config contracts against config paths and values.
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { appendConfigPathSegment } from "../shared/dot-path.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { isRecord } from "../utils.js";
@@ -8,24 +9,21 @@ type PluginConfigContractMatch = {
path: string;
/** Config value stored at the matched path. */
value: unknown;
/** Exact matched container and key so assignments update the original location directly. */
parent: Record<string, unknown> | unknown[];
key: string;
};
type TraversalState = {
segments: string[];
segments: Array<string | number>;
value: unknown;
parent?: Record<string, unknown> | unknown[];
};
function normalizePathPattern(pathPattern: string): string[] {
return normalizeStringEntries(pathPattern.split("."));
}
function appendPathSegment(path: string, segment: string): string {
if (!path) {
return segment;
}
return /^\d+$/.test(segment) ? `${path}[${segment}]` : `${path}.${segment}`;
}
function parseCanonicalArrayIndex(segment: string, length: number): number | null {
const index = parseConfigPathArrayIndex(segment);
return index !== undefined && index < length ? index : null;
@@ -50,8 +48,9 @@ export function collectPluginConfigContractMatches(params: {
if (Array.isArray(state.value)) {
for (const [index, value] of state.value.entries()) {
nextStates.push({
segments: [...state.segments, String(index)],
segments: [...state.segments, index],
value,
parent: state.value,
});
}
continue;
@@ -61,6 +60,7 @@ export function collectPluginConfigContractMatches(params: {
nextStates.push({
segments: [...state.segments, key],
value,
parent: state.value,
});
}
}
@@ -70,8 +70,9 @@ export function collectPluginConfigContractMatches(params: {
const index = parseCanonicalArrayIndex(segment, state.value.length);
if (index !== null) {
nextStates.push({
segments: [...state.segments, segment],
segments: [...state.segments, index],
value: state.value[index],
parent: state.value,
});
}
continue;
@@ -82,6 +83,7 @@ export function collectPluginConfigContractMatches(params: {
nextStates.push({
segments: [...state.segments, segment],
value: state.value[segment],
parent: state.value,
});
}
states = nextStates;
@@ -91,7 +93,9 @@ export function collectPluginConfigContractMatches(params: {
}
return states.map((state) => ({
path: state.segments.reduce(appendPathSegment, ""),
path: state.segments.reduce(appendConfigPathSegment, ""),
value: state.value,
parent: state.parent!,
key: String(state.segments.at(-1)!),
}));
}
+58 -1
View File
@@ -375,7 +375,7 @@ describe("collectPluginConfigContractMatches", () => {
root,
pathPattern: "items.1",
}),
).toEqual([{ path: "items[1]", value: "second" }]);
).toEqual([{ path: "items[1]", value: "second", parent: root.items, key: "1" }]);
expect(
collectPluginConfigContractMatches({
root,
@@ -390,6 +390,63 @@ describe("collectPluginConfigContractMatches", () => {
).toEqual([]);
});
it("preserves exact dotted wildcard keys and array-index parents", () => {
const headers = { "X.Trace": "trace-value" };
const entries = [{ headers }];
expect(
collectPluginConfigContractMatches({
root: { "sales.eu": { entries } },
pathPattern: "*.entries.*.headers.*",
}),
).toEqual([
{
path: '["sales.eu"].entries[0].headers["X.Trace"]',
value: "trace-value",
parent: headers,
key: "X.Trace",
},
]);
expect(
collectPluginConfigContractMatches({
root: { entries },
pathPattern: "entries.*",
}),
).toEqual([{ path: "entries[0]", value: entries[0], parent: entries, key: "0" }]);
});
it.each([
{ key: "X.Trace", path: 'headers["X.Trace"]' },
{ key: "0", path: 'headers["0"]' },
{ key: "01", path: 'headers["01"]' },
{ key: "value[0]", path: 'headers["value[0]"]' },
{ key: 'quoted"key', path: 'headers["quoted\\"key"]' },
{ key: "escaped\\key", path: 'headers["escaped\\\\key"]' },
{ key: "safe-header", path: "headers.safe-header" },
])("renders wildcard record key $key without path ambiguity", ({ key, path }) => {
const headers = { [key]: "value" };
expect(
collectPluginConfigContractMatches({ root: { headers }, pathPattern: "headers.*" }),
).toEqual([{ path, value: "value", parent: headers, key }]);
});
it("keeps dotted wildcard keys distinct from explicitly nested record keys", () => {
const root = {
"alpha.beta": { token: "dotted" },
alpha: { beta: { token: "nested" } },
};
expect(
collectPluginConfigContractMatches({ root, pathPattern: "*.token" }).map(({ path }) => path),
).toEqual(['["alpha.beta"].token']);
expect(
collectPluginConfigContractMatches({ root, pathPattern: "*.*.token" }).map(
({ path }) => path,
),
).toEqual(["alpha.beta.token"]);
});
it("rejects array indexes outside canonical config path bounds", () => {
const items = Array<string>(100_002);
items[100_001] = "too far";
+27 -3
View File
@@ -1263,7 +1263,12 @@ describe("secrets apply", () => {
openai: {
...createOpenAiProviderConfig(),
headers: {
"x-api-key": "sk-header-plaintext",
"X.Trace": "sk-header-plaintext",
},
request: {
headers: {
"X.Request.Trace": "sk-request-header-plaintext",
},
},
},
},
@@ -1273,8 +1278,23 @@ describe("secrets apply", () => {
const plan = createPlan({
targets: [
createOpenAiProviderHeaderTarget({
pathSegments: ["models", "providers", "openai", "headers", "x-api-key"],
path: 'models.providers.openai.headers["X.Trace"]',
pathSegments: ["models", "providers", "openai", "headers", "X.Trace"],
}),
{
...createOpenAiProviderHeaderTarget({
path: 'models.providers.openai.request.headers["X.Request.Trace"]',
pathSegments: [
"models",
"providers",
"openai",
"request",
"headers",
"X.Request.Trace",
],
}),
type: "models.providers.request.headers",
},
],
options: {
scrubEnv: false,
@@ -1291,11 +1311,15 @@ describe("secrets apply", () => {
providers?: {
openai?: {
headers?: Record<string, unknown>;
request?: { headers?: Record<string, unknown> };
};
};
};
};
expect(nextConfig.models?.providers?.openai?.headers?.["x-api-key"]).toEqual(
expect(nextConfig.models?.providers?.openai?.headers?.["X.Trace"]).toEqual(
OPENAI_API_KEY_ENV_REF,
);
expect(nextConfig.models?.providers?.openai?.request?.headers?.["X.Request.Trace"]).toEqual(
OPENAI_API_KEY_ENV_REF,
);
});
+8 -8
View File
@@ -420,11 +420,11 @@ function applyConfigTargetMutations(params: {
if (isNonEmptyString(previous)) {
scrubbedValues.add(previous.trim());
}
const refPathSegments = resolved.refPathSegments;
if (!refPathSegments) {
const refPathTokens = resolved.refPathTokens;
if (!refPathTokens) {
throw new Error(`Missing sibling ref path for target ${target.type}.`);
}
const wroteRef = setPathCreateStrict(params.nextConfig, refPathSegments, target.ref);
const wroteRef = setPathCreateStrict(params.nextConfig, refPathTokens, target.ref);
const deletedLegacy = deletePathStrict(params.nextConfig, targetPathSegments);
if (wroteRef || deletedLegacy) {
configChanged = true;
@@ -436,7 +436,7 @@ function applyConfigTargetMutations(params: {
if (isNonEmptyString(previous)) {
scrubbedValues.add(previous.trim());
}
const wroteRef = setPathCreateStrict(params.nextConfig, targetPathSegments, target.ref);
const wroteRef = setPathCreateStrict(params.nextConfig, resolved.pathTokens, target.ref);
if (wroteRef) {
configChanged = true;
}
@@ -678,11 +678,11 @@ function applyAuthProfileTargetMutation(params: {
if (isNonEmptyString(previous)) {
params.scrubbedValues.add(previous.trim());
}
const refPathSegments = params.resolved.refPathSegments;
if (!refPathSegments) {
const refPathTokens = params.resolved.refPathTokens;
if (!refPathTokens) {
throw new Error(`Missing sibling ref path for auth-profiles target ${params.target.path}.`);
}
const wroteRef = setPathCreateStrict(store, refPathSegments, params.target.ref);
const wroteRef = setPathCreateStrict(store, refPathTokens, params.target.ref);
const deletedPlaintext = deletePathStrict(store, targetPathSegments);
changed = changed || wroteRef || deletedPlaintext;
return changed;
@@ -691,7 +691,7 @@ function applyAuthProfileTargetMutation(params: {
if (isNonEmptyString(previous)) {
params.scrubbedValues.add(previous.trim());
}
const wroteRef = setPathCreateStrict(store, targetPathSegments, params.target.ref);
const wroteRef = setPathCreateStrict(store, params.resolved.pathTokens, params.target.ref);
changed = changed || wroteRef;
return changed;
}
@@ -237,13 +237,13 @@ describe("exec SecretRef id parity", () => {
if (token.kind === "literal") {
return [];
}
return [token.kind === "array" ? "0" : "sample"];
return [token.kind === "array" ? 0 : "sample"];
});
const segments = materializePathTokens(tokens, captures);
if (!segments) {
throw new Error(`failed to sample path segments for pattern "${entry.pathPattern}"`);
}
return segments;
return segments.map(String);
}
const registryPlanTargets = listSecretTargetRegistryEntries().filter(
+29 -2
View File
@@ -49,9 +49,9 @@ describe("secrets path utils", () => {
const config = createAgentListConfig();
expect(() =>
setPathCreateStrict(config, ["agents", "list", "9007199254740993", "id"], "b"),
setPathCreateStrict(config, ["agents", "list", Number.MAX_SAFE_INTEGER + 2, "id"], "b"),
).toThrow(/Invalid array index segment/);
expect(() => setPathCreateStrict(config, ["agents", "list", "4294967294", "id"], "b")).toThrow(
expect(() => setPathCreateStrict(config, ["agents", "list", 4294967294, "id"], "b")).toThrow(
/Invalid array index segment/,
);
expect(() => setPathCreateStrict(config, ["agents", "list", "+0", "id"], "b")).toThrow(
@@ -119,6 +119,33 @@ describe("secrets path utils", () => {
expect(getPath(config, ["talk", "provider", "apiKey"])).toBe("x");
});
it.each([
{
name: "array index",
segment: 0,
expected: { accounts: [{ token: "secret" }] },
mismatched: { accounts: { "0": { token: "old" } } },
},
{
name: "numeric record key",
segment: "0",
expected: { accounts: { "0": { token: "secret" } } },
mismatched: { accounts: [{ token: "old" }] },
},
])(
"setPathCreateStrict preserves the $name container contract",
({ segment, expected, mismatched }) => {
const config = asConfig({});
expect(setPathCreateStrict(config, ["accounts", segment, "token"], "secret")).toBe(true);
expect(config).toEqual(expected);
expect(() =>
setPathCreateStrict(asConfig(mismatched), ["accounts", segment, "token"], "secret"),
).toThrow(/Invalid path shape/);
expect(mismatched.accounts[0].token).toBe("old");
},
);
it("setPathCreateStrict leaves value unchanged when equal", () => {
const config = asConfig({
talk: {
+24 -27
View File
@@ -1,13 +1,10 @@
/** Strict dotted-path get/set/delete helpers for secrets migration targets. */
import { isDeepStrictEqual } from "node:util";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import type { ConcreteConfigPathSegment } from "../shared/dot-path.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { isRecord } from "./shared.js";
function looksLikeArrayIndexSegment(segment: string): boolean {
return /^\d+$/.test(segment);
}
function parseArrayIndexSegment(segment: string): number | undefined {
return parseConfigPathArrayIndex(segment);
}
@@ -20,15 +17,13 @@ function requireArrayIndexSegment(segment: string, pathLabel: string): number {
return index;
}
function expectedContainer(nextSegment: string): "array" | "object" {
return looksLikeArrayIndexSegment(nextSegment) ? "array" : "object";
}
function assertSafeMutationPath(segments: string[]): void {
function assertSafeMutationPath(segments: readonly ConcreteConfigPathSegment[]): void {
if (segments.length === 0) {
throw new Error("Target path is empty.");
}
const blockedSegment = segments.find(isBlockedObjectKey);
const blockedSegment = segments.find(
(segment) => typeof segment === "string" && isBlockedObjectKey(segment),
);
if (blockedSegment) {
throw new Error(`Refusing to mutate prototype-polluting path segment "${blockedSegment}".`);
}
@@ -36,13 +31,13 @@ function assertSafeMutationPath(segments: string[]): void {
function parseArrayLeafTarget(
cursor: unknown,
leaf: string,
segments: string[],
leaf: ConcreteConfigPathSegment,
segments: readonly ConcreteConfigPathSegment[],
): { array: unknown[]; index: number } | null {
if (!Array.isArray(cursor)) {
return null;
}
return { array: cursor, index: requireArrayIndexSegment(leaf, segments.join(".")) };
return { array: cursor, index: requireArrayIndexSegment(String(leaf), segments.join(".")) };
}
function traverseToLeafParent(params: {
@@ -109,12 +104,11 @@ export function getPath(root: unknown, segments: string[]): unknown {
}
/**
* Sets a config path, creating missing object or array containers from the next path segment.
* Existing non-container parents fail so callers cannot silently change config shape.
* Sets a config path using token types as the sole authority for object-versus-array shape.
*/
export function setPathCreateStrict(
root: Record<string, unknown>,
segments: string[],
segments: readonly ConcreteConfigPathSegment[],
value: unknown,
): boolean {
assertSafeMutationPath(segments);
@@ -123,38 +117,41 @@ export function setPathCreateStrict(
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index] ?? "";
const nextSegment = segments[index + 1] ?? "";
const needs = expectedContainer(nextSegment);
const needsArray = typeof segments[index + 1] === "number";
// Numeric next segments create arrays; named next segments create objects.
// This keeps registry wildcard paths and config array paths materialized consistently.
if (Array.isArray(cursor)) {
const arrayIndex = requireArrayIndexSegment(segment, segments.join("."));
if (typeof segment !== "number") {
throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || "<root>"}.`);
}
const arrayIndex = requireArrayIndexSegment(String(segment), segments.join("."));
const existing = cursor[arrayIndex];
if (existing === undefined || existing === null) {
cursor[arrayIndex] = needs === "array" ? [] : {};
cursor[arrayIndex] = needsArray ? [] : {};
changed = true;
} else if (needs === "array" ? !Array.isArray(existing) : !isRecord(existing)) {
} else if (needsArray ? !Array.isArray(existing) : !isRecord(existing)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index + 1).join(".")}.`);
}
cursor = cursor[arrayIndex];
continue;
}
if (!isRecord(cursor)) {
if (!isRecord(cursor) || typeof segment !== "string") {
throw new Error(`Invalid path shape at ${segments.slice(0, index).join(".") || "<root>"}.`);
}
const existing = cursor[segment];
if (existing === undefined || existing === null) {
cursor[segment] = needs === "array" ? [] : {};
cursor[segment] = needsArray ? [] : {};
changed = true;
} else if (needs === "array" ? !Array.isArray(existing) : !isRecord(existing)) {
} else if (needsArray ? !Array.isArray(existing) : !isRecord(existing)) {
throw new Error(`Invalid path shape at ${segments.slice(0, index + 1).join(".")}.`);
}
cursor = cursor[segment];
}
const leaf = segments[segments.length - 1] ?? "";
if (Array.isArray(cursor) !== (typeof leaf === "number")) {
throw new Error(`Invalid path shape at ${segments.slice(0, -1).join(".") || "<root>"}.`);
}
const arrayTarget = parseArrayLeafTarget(cursor, leaf, segments);
if (arrayTarget) {
if (!isDeepStrictEqual(arrayTarget.array[arrayTarget.index], value)) {
@@ -163,7 +160,7 @@ export function setPathCreateStrict(
}
return changed;
}
if (!isRecord(cursor)) {
if (!isRecord(cursor) || typeof leaf !== "string") {
throw new Error(`Invalid path shape at ${segments.slice(0, -1).join(".") || "<root>"}.`);
}
if (!isDeepStrictEqual(cursor[leaf], value)) {
+26 -6
View File
@@ -4,9 +4,11 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-norm
import type { SecretProviderConfig, SecretRef } from "../config/types.secrets.js";
import { SecretProviderSchema } from "../config/zod-schema.core.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { toDotPath } from "../shared/dot-path.js";
import {
parseConcreteConfigPathTokens,
type ConcreteConfigPathSegment,
} from "../shared/dot-path.js";
import { isValidSecretProviderAlias, isValidSecretRef } from "./ref-contract.js";
import { parseDotPath } from "./shared.js";
import { resolvePlanTargetAgainstRegistry, type ResolvedPlanTarget } from "./target-registry.js";
/** Registry target id accepted by a secrets apply plan. */
@@ -83,11 +85,26 @@ export function resolveValidatedPlanTarget(candidate: {
if (!path) {
return null;
}
const segments =
Array.isArray(candidate.pathSegments) && candidate.pathSegments.length > 0
let parsedTokens: ConcreteConfigPathSegment[];
let segments: string[];
const hasPathSegments =
Array.isArray(candidate.pathSegments) && candidate.pathSegments.length > 0;
try {
parsedTokens = parseConcreteConfigPathTokens(path);
segments = hasPathSegments
? normalizeStringEntries(candidate.pathSegments)
: parseDotPath(path);
if (segments.length === 0 || segments.some(isBlockedObjectKey) || path !== toDotPath(segments)) {
: parsedTokens.map(String);
} catch {
return null;
}
const parsedPathMatches =
segments.length === parsedTokens.length &&
segments.every((segment, index) => segment === String(parsedTokens[index]));
if (
segments.length === 0 ||
segments.some(isBlockedObjectKey) ||
(!parsedPathMatches && path !== segments.join("."))
) {
return null;
}
// Registry resolution is the ownership gate; caller-provided paths must map to a known
@@ -95,6 +112,9 @@ export function resolveValidatedPlanTarget(candidate: {
return resolvePlanTargetAgainstRegistry({
type: candidate.type,
pathSegments: segments,
pathTokens: parsedPathMatches ? parsedTokens : segments,
// Only an authored array pattern can disambiguate indices in shipped v1 dotted plans.
allowLegacyArrayString: path === segments.join("."),
providerId: candidate.providerId,
accountId: candidate.accountId,
});
+11 -16
View File
@@ -1,7 +1,7 @@
/** Shared plan construction for plugin-owned SecretRef setup commands. */
import { isValidAgentId } from "@openclaw/normalization-core/agent-id";
import type { PluginIntegrationSecretProviderConfig, SecretRef } from "../config/types.secrets.js";
import { toDotPath } from "../shared/dot-path.js";
import { formatConcreteConfigPath, parseConcreteConfigPathTokens } from "../shared/dot-path.js";
import type { SecretsApplyPlan, SecretsPlanTarget } from "./plan.js";
import { resolveSecretPlanTargetByPathCore } from "./target-registry-query.js";
@@ -18,14 +18,6 @@ type PluginSecretRefConfigTargetMapping = {
const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
const MODEL_PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const FORBIDDEN_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function parseDotPath(pathname: string): string[] {
return pathname
.split(".")
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0);
}
export function assertValidPluginSecretProviderAlias(value: string): void {
if (!SECRET_PROVIDER_ALIAS_PATTERN.test(value)) {
@@ -89,18 +81,21 @@ function createPluginConfigSecretTarget(params: {
if (params.agentId && !isValidAgentId(params.agentId)) {
throw new Error(`Invalid ${params.productName} setup agent id: ${params.agentId}`);
}
const pathSegments = parseDotPath(params.path);
const normalizedPath = toDotPath(pathSegments);
if (
pathSegments.length === 0 ||
normalizedPath !== params.path ||
pathSegments.some((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment))
) {
let parsedPath: Array<string | number>;
try {
parsedPath = parseConcreteConfigPathTokens(params.path);
} catch {
throw new Error(`Invalid --target config path: ${params.path}`);
}
const pathSegments = parsedPath.map(String);
const normalizedPath = formatConcreteConfigPath(parsedPath);
if (normalizedPath !== params.path) {
throw new Error(`Invalid --target config path: ${params.path}`);
}
const resolved = resolveSecretPlanTargetByPathCore({
configFile: params.agentId ? "auth-profile-store" : "openclaw.json",
pathSegments,
pathTokens: parsedPath,
});
if (!resolved) {
throw new Error(
@@ -3,6 +3,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../config/home-env.test-harness.js";
import {
createConfigResolutionFacts,
getAuthoredConfigSecretRef,
setConfigResolutionFacts,
} from "../config/resolution-facts.js";
import { resolveAuthProfileSecretOwnerId } from "./runtime-auth-profile-owner.js";
import {
beginSecretsRuntimeIsolationForTest,
@@ -136,6 +141,11 @@ describe("secrets runtime snapshot auth refresh failure", () => {
loadablePluginOrigins: EMPTY_LOADABLE_PLUGIN_ORIGINS,
loadAuthStore,
});
const discordTokenPath = "channels.discord.accounts.ops.token";
setConfigResolutionFacts(
prepared.config,
createConfigResolutionFacts([], new Map([[discordTokenPath, "DISCORD_BOT_TOKEN"]])),
);
prepared.secretOwners = [
...(prepared.secretOwners ?? []),
{
@@ -166,6 +176,9 @@ describe("secrets runtime snapshot auth refresh failure", () => {
activeRef = secondRef;
await expect(refreshActiveProviderAuthRuntimeSnapshot()).resolves.toBe(true);
expect(
getAuthoredConfigSecretRef(expectActiveSecretsRuntimeSnapshot().config, discordTokenPath),
).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
expect(expectActiveSecretsRuntimeSnapshot().secretOwners).toContainEqual({
ownerKind: "account",
ownerId: "discord:ops",
@@ -16,6 +16,7 @@ function envRef(id: string) {
const explicitMainRoster: NonNullable<OpenClawConfig["agents"]> = {
list: [{ id: "main", default: true }],
};
const isolatedEnv: NodeJS.ProcessEnv = { OPENCLAW_STATE_DIR: process.env.OPENCLAW_TEST_HOME };
describe("collectPluginConfigAssignments bundled plugin manifests", () => {
it("assigns each webhooks route SecretRef to its exact runtime owner", () => {
@@ -43,7 +44,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
},
},
} as OpenClawConfig;
const context = createResolverContext({ sourceConfig: config, env: {} });
const context = createResolverContext({ sourceConfig: config, env: isolatedEnv });
collectPluginConfigAssignments({
config,
@@ -98,7 +99,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
resolvePluginConfigContractsById({
config,
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
env: {},
env: isolatedEnv,
fallbackToBundledMetadata: true,
fallbackToBundledMetadataForResolvedBundled: true,
pluginIds: ["codex"],
@@ -110,7 +111,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
]);
const context = createResolverContext({
sourceConfig: config,
env: {},
env: isolatedEnv,
});
collectPluginConfigAssignments({
@@ -169,7 +170,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
},
},
} as OpenClawConfig;
const env = { GEMINI_GATEWAY_TOKEN: "resolved-gateway-token" };
const env = { ...isolatedEnv, GEMINI_GATEWAY_TOKEN: "resolved-gateway-token" };
const context = createResolverContext({ sourceConfig: config, env });
collectPluginConfigAssignments({
@@ -252,7 +253,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
resolvePluginConfigContractsById({
config,
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
env: {},
env: isolatedEnv,
fallbackToBundledMetadata: true,
fallbackToBundledMetadataForResolvedBundled: true,
pluginIds: ["voice-call"],
@@ -266,7 +267,7 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
]);
const context = createResolverContext({
sourceConfig: config,
env: {},
env: isolatedEnv,
});
collectPluginConfigAssignments({
@@ -321,13 +322,13 @@ describe("collectPluginConfigAssignments bundled plugin manifests", () => {
expect(
resolvePluginConfigContractsById({
config,
env: {},
env: isolatedEnv,
pluginIds: ["google-meet"],
}).get("google-meet")?.configContracts.secretInputs?.paths,
).toEqual([{ path: "realtime.providers.*.apiKey", expected: "string" }]);
const context = createResolverContext({
sourceConfig: config,
env: {},
env: isolatedEnv,
});
collectPluginConfigAssignments({
@@ -36,7 +36,7 @@ function makeContext(
): ResolverContext {
return createResolverContext({
sourceConfig,
env: {},
env: { OPENCLAW_STATE_DIR: process.env.OPENCLAW_TEST_HOME },
...(manifestRegistry ? { manifestRegistry } : {}),
});
}
@@ -164,6 +164,128 @@ describe("collectPluginConfigAssignments", () => {
expect(assignment.expected).toBe("string");
});
it("keeps dotted plugin identities and plugin-local route paths distinct", () => {
const pluginIds = ["foo.config.bar", "foo"] as const;
loadPluginManifestRegistryForPluginRegistryMock.mockReturnValue({
plugins: pluginIds.map((id) => ({
id,
origin: "config",
configContracts: {
secretInputs: {
paths: [
{
path: id === "foo" ? "bar.config.token" : "token",
ownerKind: "route",
},
],
},
},
})),
diagnostics: [],
});
const context = collectAssignments(
asConfig({
plugins: {
entries: {
"foo.config.bar": { enabled: true, config: { token: envRef("DOTTED_TOKEN") } },
foo: {
enabled: true,
config: { bar: { config: { token: envRef("NESTED_TOKEN") } } },
},
},
},
}),
pluginIds.map((id) => [id, "config"]),
);
expect(context.assignments).toMatchObject([
{
path: 'plugins.entries["foo.config.bar"].config.token',
ownerKind: "route",
ownerId: 'plugins.entries["foo.config.bar"].config.token',
ref: { id: "DOTTED_TOKEN" },
},
{
path: "plugins.entries.foo.config.bar.config.token",
ownerKind: "route",
ownerId: "plugins.entries.foo.config.bar.config.token",
ref: { id: "NESTED_TOKEN" },
},
]);
expect(new Set(context.assignments.map(({ ownerId }) => ownerId)).size).toBe(2);
});
it("collects and applies dotted wildcard keys separately from nested record keys", () => {
loadPluginManifestRegistryForPluginRegistryMock.mockReturnValue({
plugins: [
{
id: "distinct-keys",
origin: "config",
configContracts: {
secretInputs: {
paths: [{ path: "*.token" }, { path: "*.*.token" }],
},
},
},
],
diagnostics: [],
});
const config = createPluginConfig("distinct-keys", {
"alpha.beta": { token: envRef("DOTTED_TOKEN") },
alpha: { beta: { token: envRef("NESTED_TOKEN") } },
});
const context = collectAssignments(config, [["distinct-keys", "config"]]);
expect(context.assignments).toMatchObject([
{
path: 'plugins.entries.distinct-keys.config["alpha.beta"].token',
ref: { id: "DOTTED_TOKEN" },
},
{
path: "plugins.entries.distinct-keys.config.alpha.beta.token",
ref: { id: "NESTED_TOKEN" },
},
]);
requireAssignment(context, 0).apply("resolved-dotted");
requireAssignment(context, 1).apply("resolved-nested");
expect(config.plugins?.entries?.["distinct-keys"]?.config).toMatchObject({
"alpha.beta": { token: "resolved-dotted" },
alpha: { beta: { token: "resolved-nested" } },
});
});
it("keeps installed web-provider headers unknown while applying exact dotted keys", () => {
loadPluginManifestRegistryForPluginRegistryMock.mockReturnValue({
plugins: [
{
id: "custom-search",
origin: "config",
contracts: { webSearchProviders: ["custom-search"] },
configContracts: {
secretInputs: { paths: [{ path: "webSearch.headers.*", expected: "string" }] },
},
},
],
diagnostics: [],
});
const config = createPluginConfig("custom-search", {
webSearch: { headers: { "X.Trace": envRef("CUSTOM_TRACE") } },
});
const context = collectAssignments(config, [["custom-search", "config"]]);
expect(context.assignments).toMatchObject([
{
path: 'plugins.entries.custom-search.config.webSearch.headers["X.Trace"]',
ownerKind: "unknown",
},
]);
requireAssignment(context, 0).apply("resolved-trace");
expect(config.plugins?.entries?.["custom-search"]?.config).toMatchObject({
webSearch: { headers: { "X.Trace": "resolved-trace" } },
});
});
it("collects contracts from a secondary agent workspace registry", () => {
loadPluginManifestRegistryForPluginRegistryMock.mockReturnValue({
plugins: [
@@ -1,5 +1,4 @@
/** Collects plugin config secret refs from runtime plugin metadata. */
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { resolveConfigWidePluginManifestRegistry } from "../config/io.plugin-metadata.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -8,7 +7,7 @@ import {
} from "../plugins/config-contracts.js";
import { normalizePluginsConfig, resolveEnableState } from "../plugins/config-state.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { formatConcreteConfigPath } from "../shared/dot-path.js";
import {
collectRuntimeSecretInputAssignment,
type ResolverContext,
@@ -16,10 +15,6 @@ import {
} from "./runtime-shared.js";
import { isRecord } from "./shared.js";
function parsePluginConfigArrayIndex(segment: string): number | undefined {
return parseConfigPathArrayIndex(segment);
}
/**
* Walk manifest-declared plugin config SecretRef surfaces and collect
* assignments for runtime materialization. Plugin-owned metadata controls which
@@ -143,13 +138,20 @@ function collectConfiguredPluginSecretAssignments(params: {
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const pluginConfigPath = formatConcreteConfigPath([
"plugins",
"entries",
params.pluginId,
"config",
]);
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}`;
const relativePath = match.path.startsWith("[") ? match.path : `.${match.path}`;
const fullPath = `${pluginConfigPath}${relativePath}`;
if (seenPaths.has(fullPath)) {
continue;
}
@@ -177,44 +179,10 @@ function collectConfiguredPluginSecretAssignments(params: {
},
}
: {}),
apply: createPluginConfigAssignmentApply(params.pluginConfig, match.path),
apply: (value) => {
Reflect.set(match.parent, match.key, value);
},
});
}
}
}
function createPluginConfigAssignmentApply(
pluginConfig: Record<string, unknown>,
relativePath: string,
): (value: unknown) => void {
return (value) => {
// Manifest paths use dotted/bracket notation; assignment writes need concrete object/array steps.
const segments = normalizeStringEntries(relativePath.replace(/\[(\d+)\]/g, ".$1").split("."));
if (segments.length === 0) {
return;
}
let current: unknown = pluginConfig;
for (const segment of segments.slice(0, -1)) {
if (Array.isArray(current)) {
const index = parsePluginConfigArrayIndex(segment);
current = index !== undefined && index < current.length ? current[index] : undefined;
continue;
}
current = isRecord(current) ? current[segment] : undefined;
}
const finalSegment = segments.at(-1);
if (!finalSegment) {
return;
}
if (Array.isArray(current)) {
const index = parsePluginConfigArrayIndex(finalSegment);
if (index !== undefined && index < current.length) {
current[index] = value;
}
return;
}
if (isRecord(current)) {
current[finalSegment] = value;
}
};
}
+3 -3
View File
@@ -418,7 +418,7 @@ function addCoveragePluginLoadPath(config: OpenClawConfig, pluginId: string): vo
return;
}
const nextIndex = Array.isArray(existing) ? existing.length : 0;
setPathCreateStrict(config, ["plugins", "load", "paths", String(nextIndex)], loadPath);
setPathCreateStrict(config, ["plugins", "load", "paths", nextIndex], loadPath);
}
function resolveCoverageLoadablePluginOrigins(
@@ -578,7 +578,7 @@ function applyConfigForOpenClawTarget(
}
}
if (entry.id === "memory.search.remote.apiKey") {
setPathCreateStrict(config, ["agents", "list", "0", "id"], "sample-agent");
setPathCreateStrict(config, ["agents", "list", 0, "id"], "sample-agent");
}
if (entry.id === "gateway.auth.password") {
setPathCreateStrict(config, ["gateway", "auth", "mode"], "password");
@@ -848,7 +848,7 @@ async function expectOpenClawCoverageBatchResolved(
): Promise<void> {
logCoverageBatch(label, batch);
const config = {} as OpenClawConfig;
const env: Record<string, string> = {};
const env: NodeJS.ProcessEnv = { OPENCLAW_STATE_DIR: process.env.OPENCLAW_TEST_HOME };
for (const [index, entry] of batch.entries()) {
const envId = toCoverageEnvRefId("OPENCLAW_SECRET_TARGET", entry.id);
const runtimeEnvId = resolveCoverageEnvId(entry, envId);
+1 -1
View File
@@ -655,7 +655,7 @@ export async function refreshActiveProviderAuthRuntimeSnapshot(): Promise<boolea
if (!runtimeConfig || !runtimeSourceConfig || !runtimeMetadata) {
return false;
}
const config = { ...runtimeConfig };
const config = cloneConfigWithResolutionFacts(runtimeConfig);
const modelsPatch = patchResolvedSecretRefLeaves({
current: runtimeConfig.models,
source: providerAuthConfig.models,
@@ -19,7 +19,8 @@ vi.mock("../plugins/bundled-plugin-metadata.js", () => ({
listBundledPluginMetadata: metadataMocks.listBundledPluginMetadata,
}));
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
vi.mock("../plugins/plugin-metadata-snapshot.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../plugins/plugin-metadata-snapshot.js")>()),
resolvePluginMetadataSnapshot: metadataMocks.resolvePluginMetadataSnapshot,
}));
@@ -132,6 +133,256 @@ describe("getSecretTargetRegistry metadata reuse", () => {
expect(metadataMocks.listBundledPluginMetadata).not.toHaveBeenCalled();
});
it("preserves plugin, array, and record identity across discovery, setup, and apply", async () => {
const rootDir = makeTrackedTempDir("openclaw-target-registry-plugin-identity", tempDirs);
const pluginContracts = [
{ id: "foo.config.bar", secretPath: "token", refId: "DOTTED_PLUGIN_TOKEN" },
{ id: "foo", secretPath: "bar.config.token", refId: "NESTED_PLUGIN_TOKEN" },
{ id: "array-plugin", secretPath: "accounts[].token", refId: "ARRAY_PLUGIN_TOKEN" },
{ id: "record-plugin", secretPath: "accounts.*.token", refId: "RECORD_PLUGIN_TOKEN" },
{
id: "wildcard-array-plugin",
secretPath: "accounts.*.token",
refId: "WILDCARD_ARRAY_PLUGIN_TOKEN",
},
];
const plugins = pluginContracts.map(({ id, secretPath }, index) => {
const pluginRoot = path.join(rootDir, `plugin-${index}`);
fs.mkdirSync(pluginRoot);
fs.writeFileSync(
path.join(pluginRoot, "index.js"),
`export default { id: ${JSON.stringify(id)}, register() {} };`,
);
const manifest = {
id,
configSchema: { type: "object", additionalProperties: true },
configContracts: { secretInputs: { paths: [{ path: secretPath }] } },
};
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), JSON.stringify(manifest));
return { ...manifest, origin: "config", channels: [], rootDir: pluginRoot };
});
metadataMocks.resolvePluginMetadataSnapshot.mockReturnValue({
plugins,
manifestRegistry: { plugins, diagnostics: [] },
} as never);
const config = {
plugins: {
load: { paths: plugins.map((plugin) => plugin.rootDir) },
entries: {
"foo.config.bar": { enabled: true, config: { token: "dotted-plaintext" } },
foo: { enabled: true, config: { bar: { config: { token: "nested-plaintext" } } } },
"array-plugin": { enabled: true, config: { accounts: [{ token: "array-plaintext" }] } },
"record-plugin": {
enabled: true,
config: {
accounts: {
"0": { token: "numeric-record-plaintext" },
"foo.bar": { token: "dotted-record-plaintext" },
},
},
},
"wildcard-array-plugin": {
enabled: true,
config: { accounts: [{ token: "wildcard-array-plaintext" }] },
},
},
},
};
const expectedTargets = [
{
path: 'plugins.entries["foo.config.bar"].config.token',
pathSegments: ["plugins", "entries", "foo.config.bar", "config", "token"],
},
{
path: "plugins.entries.foo.config.bar.config.token",
pathSegments: ["plugins", "entries", "foo", "config", "bar", "config", "token"],
},
{
path: "plugins.entries.array-plugin.config.accounts[0].token",
pathSegments: ["plugins", "entries", "array-plugin", "config", "accounts", "0", "token"],
},
{
path: 'plugins.entries.record-plugin.config.accounts["0"].token',
pathSegments: ["plugins", "entries", "record-plugin", "config", "accounts", "0", "token"],
},
{
path: 'plugins.entries.record-plugin.config.accounts["foo.bar"].token',
pathSegments: [
"plugins",
"entries",
"record-plugin",
"config",
"accounts",
"foo.bar",
"token",
],
},
{
path: "plugins.entries.wildcard-array-plugin.config.accounts[0].token",
pathSegments: [
"plugins",
"entries",
"wildcard-array-plugin",
"config",
"accounts",
"0",
"token",
],
},
];
const { getSecretTargetRegistry } = await import("./target-registry-data.js");
const { discoverConfigSecretTargets } = await import("./target-registry-query.js");
const { buildConfigureCandidatesForScope, buildSecretsConfigurePlan } =
await import("./configure-plan.js");
const { isSecretsApplyPlan, resolveValidatedPlanTarget } = await import("./plan.js");
expect(getSecretTargetRegistry({ config, env: {} })).toEqual(
expect.arrayContaining(
pluginContracts.map(({ id, secretPath }) => {
const pluginPath = id.includes(".") ? `[${JSON.stringify(id)}]` : `.${id}`;
const pathPattern = `plugins.entries${pluginPath}.config.${secretPath}`;
return expect.objectContaining({
id: pathPattern,
pathPattern,
pathPatternSegments: ["plugins", "entries", id, "config", ...secretPath.split(".")],
});
}),
),
);
expect(discoverConfigSecretTargets(config)).toEqual(
expect.arrayContaining(expectedTargets.map((target) => expect.objectContaining(target))),
);
const candidates = buildConfigureCandidatesForScope({ config }).filter((candidate) =>
candidate.path.startsWith("plugins.entries"),
);
expect(candidates).toEqual(
expect.arrayContaining(expectedTargets.map((target) => expect.objectContaining(target))),
);
const plan = buildSecretsConfigurePlan({
selectedTargets: new Map(
candidates.map((candidate) => {
const plugin = pluginContracts.find(({ id }) => candidate.pathSegments[2] === id)!;
return [
candidate.path,
{
...candidate,
ref: { source: "env" as const, provider: "default", id: plugin.refId },
},
];
}),
),
providerChanges: { upserts: {}, deletes: [] },
});
expect(plan.targets).toHaveLength(expectedTargets.length);
const arrayTarget = plan.targets.find((target) => target.pathSegments?.[2] === "array-plugin")!;
const recordTarget = plan.targets.find(
(target) => target.pathSegments?.[2] === "record-plugin" && target.pathSegments[5] === "0",
)!;
const wildcardArrayTarget = plan.targets.find(
(target) => target.pathSegments?.[2] === "wildcard-array-plugin",
)!;
const dottedPluginTarget = plan.targets.find(
(target) => target.pathSegments?.[2] === "foo.config.bar",
)!;
const quotedArrayPath = 'plugins.entries.array-plugin.config.accounts["0"].token';
const legacyArrayTarget = {
...arrayTarget,
path: arrayTarget.pathSegments!.join("."),
};
const legacyRecordTarget = {
...recordTarget,
path: recordTarget.pathSegments!.join("."),
};
expect(resolveValidatedPlanTarget(arrayTarget)?.pathTokens[5]).toBe(0);
expect(resolveValidatedPlanTarget(recordTarget)?.pathTokens[5]).toBe("0");
expect(resolveValidatedPlanTarget(wildcardArrayTarget)?.pathTokens[5]).toBe(0);
expect(Object.hasOwn(arrayTarget, "pathTokens")).toBe(false);
expect(resolveValidatedPlanTarget({ ...arrayTarget, path: quotedArrayPath })).toBeNull();
expect(resolveValidatedPlanTarget(legacyArrayTarget)?.pathTokens[5]).toBe(0);
expect(resolveValidatedPlanTarget(legacyRecordTarget)?.pathTokens[5]).toBe("0");
expect(
resolveValidatedPlanTarget({ ...legacyArrayTarget, pathSegments: undefined })?.pathTokens[5],
).toBe(0);
expect(
resolveValidatedPlanTarget({ ...legacyRecordTarget, pathSegments: undefined })?.pathTokens[5],
).toBe("0");
expect(
resolveValidatedPlanTarget({
...dottedPluginTarget,
path: dottedPluginTarget.pathSegments!.join("."),
}),
).not.toBeNull();
const forgedArrayPlan = {
...plan,
generatedBy: "manual" as const,
targets: [{ ...arrayTarget, path: quotedArrayPath }],
};
expect(isSecretsApplyPlan(forgedArrayPlan)).toBe(false);
const { buildPluginSecretRefSetupPlan } = await import("./plugin-setup-plan.js");
const setupPlan = buildPluginSecretRefSetupPlan({
productName: "Fixture",
providerAlias: "fixture",
providerConfig: {
source: "exec",
pluginIntegration: { pluginId: "fixture", integrationId: "fixture" },
},
providerSecrets: [],
configTargetSecrets: expectedTargets.map(({ path: targetPath }, index) => ({
path: targetPath,
secretId: `credentials/${index}`,
})),
});
expect(setupPlan.targets).toEqual(
expect.arrayContaining(expectedTargets.map((target) => expect.objectContaining(target))),
);
expect(isSecretsApplyPlan(plan)).toBe(true);
const configPath = path.join(rootDir, "openclaw.json");
fs.writeFileSync(configPath, JSON.stringify(config));
const { testing } = await import("./apply.js");
const env = {
OPENCLAW_STATE_DIR: rootDir,
OPENCLAW_CONFIG_PATH: configPath,
DOTTED_PLUGIN_TOKEN: "dotted-secret",
NESTED_PLUGIN_TOKEN: "nested-secret",
ARRAY_PLUGIN_TOKEN: "array-secret",
RECORD_PLUGIN_TOKEN: "record-secret",
WILDCARD_ARRAY_PLUGIN_TOKEN: "wildcard-array-secret",
};
await expect(testing.projectConfigForTest({ plan: forgedArrayPlan, env })).rejects.toThrow(
/Invalid plan target path/,
);
const projected = await testing.projectConfigForTest({
plan,
env,
});
expect(projected.plugins?.entries?.["foo.config.bar"]?.config).toEqual({
token: { source: "env", provider: "default", id: "DOTTED_PLUGIN_TOKEN" },
});
expect(projected.plugins?.entries?.foo?.config).toEqual({
bar: {
config: { token: { source: "env", provider: "default", id: "NESTED_PLUGIN_TOKEN" } },
},
});
expect(projected.plugins?.entries?.["array-plugin"]?.config).toEqual({
accounts: [{ token: { source: "env", provider: "default", id: "ARRAY_PLUGIN_TOKEN" } }],
});
expect(projected.plugins?.entries?.["record-plugin"]?.config).toEqual({
accounts: {
"0": { token: { source: "env", provider: "default", id: "RECORD_PLUGIN_TOKEN" } },
"foo.bar": { token: { source: "env", provider: "default", id: "RECORD_PLUGIN_TOKEN" } },
},
});
expect(projected.plugins?.entries?.["wildcard-array-plugin"]?.config).toEqual({
accounts: [
{ token: { source: "env", provider: "default", id: "WILDCARD_ARRAY_PLUGIN_TOKEN" } },
],
});
});
it("keeps official external channel secret targets without installed plugin metadata", async () => {
const { getSecretTargetRegistry } = await import("./target-registry-data.js");
+6 -3
View File
@@ -2,8 +2,10 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { formatConcreteConfigPath } from "../shared/dot-path.js";
import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js";
import { listOfficialExternalChannelSecretTargetRegistryEntries } from "./official-external-channel-secret-contract.js";
import { parseDotPath } from "./shared.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
const SECRET_INPUT_SHAPE = "secret_input"; // pragma: allowlist secret
@@ -20,14 +22,15 @@ function createPluginOpenClawConfigSecretTargetEntry(
pluginId: string,
configPath: string,
): SecretTargetRegistryEntry {
const pathPattern = ["plugins", "entries", pluginId, "config", ...configPath.split(".")].join(
".",
);
const pluginConfigPath = ["plugins", "entries", pluginId, "config"];
const pathPatternSegments = [...pluginConfigPath, ...parseDotPath(configPath)];
const pathPattern = `${formatConcreteConfigPath(pluginConfigPath)}.${configPath}`;
return {
id: pathPattern,
targetType: pathPattern,
configFile: "openclaw.json",
pathPattern,
pathPatternSegments,
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
+74 -4
View File
@@ -26,13 +26,26 @@ describe("target registry pattern helpers", () => {
it("matches wildcard and array tokens with stable capture ordering", () => {
const tokens = compilePattern("agents.list[].memory.search.providers.*.apiKey").pathTokens;
const match = matchPathTokens(
["agents", "list", "2", "memory", "search", "providers", "openai", "apiKey"],
["agents", "list", 2, "memory", "search", "providers", "openai", "apiKey"],
tokens,
);
expect(match).toEqual({
captures: ["2", "openai"],
captures: [2, "openai"],
});
expect(
matchPathTokens(
["agents", "list", "2", "memory", "search", "providers", "openai", "apiKey"],
tokens,
{ allowLegacyArrayString: true },
),
).toEqual({ captures: [2, "openai"] });
expect(
matchPathTokens(
["agents", "list", "2", "memory", "search", "providers", "openai", "apiKey"],
tokens,
),
).toBeNull();
expect(
matchPathTokens(
["agents", "list", "x", "memory", "search", "providers", "openai", "apiKey"],
@@ -65,10 +78,10 @@ describe("target registry pattern helpers", () => {
"agents.list[].memory.search.providers.*.apiKeyRef",
).refPathTokens;
expect(refTokens).toBeDefined();
expect(materializePathTokens(refTokens ?? [], ["1", "anthropic"])).toEqual([
expect(materializePathTokens(refTokens ?? [], [1, "anthropic"])).toEqual([
"agents",
"list",
"1",
1,
"memory",
"search",
"providers",
@@ -76,6 +89,7 @@ describe("target registry pattern helpers", () => {
"apiKeyRef",
]);
expect(materializePathTokens(refTokens ?? [], ["anthropic"])).toBeNull();
expect(materializePathTokens(refTokens ?? [], ["1", "anthropic"])).toBeNull();
expect(materializePathTokens(refTokens ?? [], ["01", "anthropic"])).toBeNull();
expect(materializePathTokens(refTokens ?? [], ["+1", "anthropic"])).toBeNull();
expect(materializePathTokens(refTokens ?? [], ["4294967294", "anthropic"])).toBeNull();
@@ -92,6 +106,48 @@ describe("target registry pattern helpers", () => {
});
});
it("keeps wildcard record keys distinct from array indices without excluding arrays", () => {
const tokens = compilePattern("accounts.*.token").pathTokens;
expect(matchPathTokens(["accounts", "0", "token"], tokens)).toEqual({ captures: ["0"] });
expect(matchPathTokens(["accounts", 0, "token"], tokens)).toEqual({ captures: [0] });
expect(
matchPathTokens(["accounts", 0, "token"], compilePattern("accounts.0.token").pathTokens),
).toBeNull();
});
it("normalizes legacy numeric strings only for declared array captures", () => {
const arrayTokens = compilePattern("accounts[].token").pathTokens;
const wildcardTokens = compilePattern("accounts.*.token").pathTokens;
const options = { allowLegacyArrayString: true };
expect(matchPathTokens(["accounts", "0", "token"], arrayTokens)).toBeNull();
expect(matchPathTokens(["accounts", "0", "token"], arrayTokens, options)).toEqual({
captures: [0],
});
expect(matchPathTokens(["accounts", "0", "token"], wildcardTokens, options)).toEqual({
captures: ["0"],
});
for (const invalid of ["01", "+1", "4294967294"]) {
expect(matchPathTokens(["accounts", invalid, "token"], arrayTokens, options)).toBeNull();
}
});
it("materializes wildcard sibling ref paths with their original container shape", () => {
const { pathTokens, refPathTokens } = compilePattern("accounts.*.token", "accounts.*.tokenRef");
for (const segment of ["0", 0] as const) {
const matched = matchPathTokens(["accounts", segment, "token"], pathTokens);
expect(matched).not.toBeNull();
expect(materializePathTokens(refPathTokens ?? [], matched!.captures)).toEqual([
"accounts",
segment,
"tokenRef",
]);
}
});
it("expands wildcard and array patterns over config objects", () => {
const root = {
agents: {
@@ -156,4 +212,18 @@ describe("target registry pattern helpers", () => {
},
]);
});
it("preserves numeric indices when expanding array and wildcard patterns", () => {
const root = { accounts: [{ token: "array-secret" }] };
for (const pattern of ["accounts[].token", "accounts.*.token"]) {
expect(expandPathTokens(root, compilePattern(pattern).pathTokens)).toEqual([
{
segments: ["accounts", 0, "token"],
captures: [0],
value: "array-secret",
},
]);
}
});
});
+38 -30
View File
@@ -1,4 +1,5 @@
/** Compiles, matches, and expands secret target registry path patterns. */
import type { ConcreteConfigPathSegment } from "../shared/dot-path.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { isRecord, parseDotPath } from "./shared.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
@@ -19,8 +20,8 @@ export type CompiledTargetRegistryEntry = SecretTargetRegistryEntry & {
/** Concrete config value matched by expanding a path pattern. */
type ExpandedPathMatch = {
segments: string[];
captures: string[];
segments: ConcreteConfigPathSegment[];
captures: ConcreteConfigPathSegment[];
value: unknown;
};
@@ -31,8 +32,8 @@ function countDynamicPatternTokens(tokens: PathPatternToken[]): number {
/**
* Parses a dotted target pattern into literal, wildcard, and array traversal tokens.
*/
function parsePathPattern(pathPattern: string): PathPatternToken[] {
const segments = parseDotPath(pathPattern);
function parsePathPattern(pathPattern: string, pathSegments?: string[]): PathPatternToken[] {
const segments = pathSegments ?? parseDotPath(pathPattern);
return segments.map((segment) => {
if (segment === "*") {
return { kind: "wildcard" } as const;
@@ -54,7 +55,7 @@ function parsePathPattern(pathPattern: string): PathPatternToken[] {
export function compileTargetRegistryEntry(
entry: SecretTargetRegistryEntry,
): CompiledTargetRegistryEntry {
const pathTokens = parsePathPattern(entry.pathPattern);
const pathTokens = parsePathPattern(entry.pathPattern, entry.pathPatternSegments);
const pathDynamicTokenCount = countDynamicPatternTokens(pathTokens);
const refPathTokens = entry.refPathPattern ? parsePathPattern(entry.refPathPattern) : undefined;
const refPathDynamicTokenCount = refPathTokens ? countDynamicPatternTokens(refPathTokens) : 0;
@@ -79,16 +80,17 @@ export function compileTargetRegistryEntry(
* Matches concrete path segments against compiled pattern tokens and returns dynamic captures.
*/
export function matchPathTokens(
segments: string[],
segments: readonly ConcreteConfigPathSegment[],
tokens: PathPatternToken[],
options?: { allowLegacyArrayString?: boolean },
): {
captures: string[];
captures: ConcreteConfigPathSegment[];
} | null {
const captures: string[] = [];
const captures: ConcreteConfigPathSegment[] = [];
let index = 0;
for (const token of tokens) {
if (token.kind === "literal") {
if (segments[index] !== token.value) {
if (typeof segments[index] !== "string" || segments[index] !== token.value) {
return null;
}
index += 1;
@@ -96,7 +98,7 @@ export function matchPathTokens(
}
if (token.kind === "wildcard") {
const value = segments[index];
if (!value) {
if (value === undefined || value === "") {
return null;
}
// Capture order must match materializePathTokens for sibling ref path reconstruction.
@@ -108,10 +110,16 @@ export function matchPathTokens(
return null;
}
const next = segments[index + 1];
if (!next || parseConfigPathArrayIndex(next) === undefined) {
const arrayIndex =
typeof next === "number"
? next
: options?.allowLegacyArrayString && typeof next === "string"
? parseConfigPathArrayIndex(next)
: undefined;
if (arrayIndex === undefined || parseConfigPathArrayIndex(String(arrayIndex)) !== arrayIndex) {
return null;
}
captures.push(next);
captures.push(arrayIndex);
index += 2;
}
return index === segments.length ? { captures } : null;
@@ -122,9 +130,9 @@ export function matchPathTokens(
*/
export function materializePathTokens(
tokens: PathPatternToken[],
captures: string[],
): string[] | null {
const out: string[] = [];
captures: ConcreteConfigPathSegment[],
): ConcreteConfigPathSegment[] | null {
const out: ConcreteConfigPathSegment[] = [];
let captureIndex = 0;
for (const token of tokens) {
if (token.kind === "literal") {
@@ -133,7 +141,7 @@ export function materializePathTokens(
}
if (token.kind === "wildcard") {
const value = captures[captureIndex];
if (!value) {
if (value === undefined || value === "") {
return null;
}
out.push(value);
@@ -141,7 +149,10 @@ export function materializePathTokens(
continue;
}
const arrayIndex = captures[captureIndex];
if (!arrayIndex || parseConfigPathArrayIndex(arrayIndex) === undefined) {
if (
typeof arrayIndex !== "number" ||
parseConfigPathArrayIndex(String(arrayIndex)) !== arrayIndex
) {
return null;
}
out.push(token.field, arrayIndex);
@@ -158,8 +169,8 @@ export function expandPathTokens(root: unknown, tokens: PathPatternToken[]): Exp
const walk = (
node: unknown,
tokenIndex: number,
segments: string[],
captures: string[],
segments: ConcreteConfigPathSegment[],
captures: ConcreteConfigPathSegment[],
): void => {
const token = tokens[tokenIndex];
if (!token) {
@@ -188,10 +199,13 @@ export function expandPathTokens(root: unknown, tokens: PathPatternToken[]): Exp
}
if (token.kind === "wildcard") {
if (!isRecord(node)) {
if (!Array.isArray(node) && !isRecord(node)) {
return;
}
for (const [key, value] of Object.entries(node)) {
const entries: Iterable<[ConcreteConfigPathSegment, unknown]> = Array.isArray(node)
? node.entries()
: Object.entries(node);
for (const [key, value] of entries) {
if (isLeaf) {
out.push({
segments: [...segments, key],
@@ -214,21 +228,15 @@ export function expandPathTokens(root: unknown, tokens: PathPatternToken[]): Exp
}
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const indexString = String(index);
if (isLeaf) {
out.push({
segments: [...segments, token.field, indexString],
captures: [...captures, indexString],
segments: [...segments, token.field, index],
captures: [...captures, index],
value: item,
});
continue;
}
walk(
item,
tokenIndex + 1,
[...segments, token.field, indexString],
[...captures, indexString],
);
walk(item, tokenIndex + 1, [...segments, token.field, index], [...captures, index]);
}
};
walk(root, 0, [], []);
+42 -21
View File
@@ -1,6 +1,7 @@
/** Query helpers for discovering secret target registry entries. */
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import { formatConcreteConfigPath, type ConcreteConfigPathSegment } from "../shared/dot-path.js";
import { loadChannelSecretContractApi } from "./channel-contract-api.js";
import { getPath } from "./path-utils.js";
import {
@@ -241,17 +242,19 @@ function discoverSecretTargetsFromEntries(
source: unknown,
discoveryEntries: CompiledTargetRegistryEntry[],
): DiscoveredConfigSecretTarget[] {
const formatDiscoveredPath = (segments: readonly ConcreteConfigPathSegment[]) =>
formatConcreteConfigPath(segments, source);
const out: DiscoveredConfigSecretTarget[] = [];
const seen = new Set<string>();
for (const entry of discoveryEntries) {
const expanded = expandPathTokens(source, entry.pathTokens);
for (const match of expanded) {
const resolved = toResolvedPlanTarget(entry, match.segments, match.captures);
const resolved = toResolvedPlanTarget(entry, match.captures);
if (!resolved) {
continue;
}
const key = `${entry.id}:${resolved.pathSegments.join(".")}`;
const key = JSON.stringify([entry.id, ...resolved.pathTokens]);
if (seen.has(key)) {
continue;
}
@@ -261,12 +264,12 @@ function discoverSecretTargetsFromEntries(
: undefined;
out.push({
entry,
path: resolved.pathSegments.join("."),
path: formatDiscoveredPath(match.segments),
pathSegments: resolved.pathSegments,
...(resolved.refPathSegments
? {
refPathSegments: resolved.refPathSegments,
refPath: resolved.refPathSegments.join("."),
refPath: formatDiscoveredPath(resolved.refPathTokens ?? resolved.refPathSegments),
}
: {}),
value: match.value,
@@ -282,9 +285,13 @@ function discoverSecretTargetsFromEntries(
function toResolvedPlanTarget(
entry: CompiledTargetRegistryEntry,
pathSegments: string[],
captures: string[],
captures: ConcreteConfigPathSegment[],
): ResolvedPlanTarget | null {
const pathTokens = materializePathTokens(entry.pathTokens, captures);
if (!pathTokens) {
return null;
}
const pathSegments = pathTokens.map(String);
const providerId =
entry.providerIdPathSegmentIndex !== undefined
? pathSegments[entry.providerIdPathSegmentIndex]
@@ -293,16 +300,17 @@ function toResolvedPlanTarget(
entry.accountIdPathSegmentIndex !== undefined
? pathSegments[entry.accountIdPathSegmentIndex]
: undefined;
const refPathSegments = entry.refPathTokens
const refPathTokens = entry.refPathTokens
? materializePathTokens(entry.refPathTokens, captures)
: undefined;
if (entry.refPathTokens && !refPathSegments) {
if (entry.refPathTokens && !refPathTokens) {
return null;
}
return {
entry,
pathSegments,
...(refPathSegments ? { refPathSegments } : {}),
pathTokens,
...(refPathTokens ? { refPathTokens, refPathSegments: refPathTokens.map(String) } : {}),
...(providerId ? { providerId } : {}),
...(accountId ? { accountId } : {}),
};
@@ -318,6 +326,7 @@ export function listSecretTargetRegistryEntries(): SecretTargetRegistryEntry[] {
{ id: entry.id, targetType: entry.targetType },
entry.targetTypeAliases ? { targetTypeAliases: [...entry.targetTypeAliases] } : {},
{ configFile: entry.configFile, pathPattern: entry.pathPattern },
entry.pathPatternSegments ? { pathPatternSegments: [...entry.pathPatternSegments] } : {},
entry.refPathPattern ? { refPathPattern: entry.refPathPattern } : {},
{
secretShape: entry.secretShape,
@@ -360,6 +369,8 @@ export function isKnownCoreSecretTargetId(value: unknown): value is string {
export function resolvePlanTargetAgainstRegistry(candidate: {
type: string;
pathSegments: string[];
pathTokens?: readonly ConcreteConfigPathSegment[];
allowLegacyArrayString?: boolean;
providerId?: string;
accountId?: string;
}): ResolvedPlanTarget | null {
@@ -387,6 +398,8 @@ function resolvePlanTargetAgainstEntries(
candidate: {
type: string;
pathSegments: string[];
pathTokens?: readonly ConcreteConfigPathSegment[];
allowLegacyArrayString?: boolean;
providerId?: string;
accountId?: string;
},
@@ -396,15 +409,18 @@ function resolvePlanTargetAgainstEntries(
return null;
}
const pathTokens = candidate.pathTokens ?? candidate.pathSegments;
for (const entry of entries) {
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(candidate.pathSegments, entry.pathTokens);
const matched = matchPathTokens(pathTokens, entry.pathTokens, {
allowLegacyArrayString: candidate.allowLegacyArrayString,
});
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, candidate.pathSegments, matched.captures);
const resolved = toResolvedPlanTarget(entry, matched.captures);
if (!resolved) {
continue;
}
@@ -429,19 +445,21 @@ function resolvePlanTargetAgainstEntries(
export function resolveSecretPlanTargetByPathCore(params: {
configFile: SecretTargetConfigFile;
pathSegments: string[];
pathTokens?: readonly ConcreteConfigPathSegment[];
}): ResolvedPlanTarget | null {
if (params.configFile === "openclaw.json") {
return resolveConfigSecretTargetByPath(params.pathSegments);
return resolveConfigSecretTargetByPath(params.pathSegments, params.pathTokens);
}
const pathTokens = params.pathTokens ?? params.pathSegments;
for (const entry of getCompiledSecretTargetRegistryState().authProfilesCompiledSecretTargets) {
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(params.pathSegments, entry.pathTokens);
const matched = matchPathTokens(pathTokens, entry.pathTokens);
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, params.pathSegments, matched.captures);
const resolved = toResolvedPlanTarget(entry, matched.captures);
if (resolved) {
return resolved;
}
@@ -452,16 +470,19 @@ export function resolveSecretPlanTargetByPathCore(params: {
/**
* Resolves an openclaw.json config path to the matching plan-capable secrets target.
*/
export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null {
export function resolveConfigSecretTargetByPath(
pathSegments: string[],
pathTokens: readonly ConcreteConfigPathSegment[] = pathSegments,
): ResolvedPlanTarget | null {
for (const entry of getCompiledCoreOpenClawTargetState().openClawCompiledSecretTargets) {
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(pathSegments, entry.pathTokens);
const matched = matchPathTokens(pathTokens, entry.pathTokens);
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures);
const resolved = toResolvedPlanTarget(entry, matched.captures);
if (!resolved) {
continue;
}
@@ -477,11 +498,11 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(pathSegments, entry.pathTokens);
const matched = matchPathTokens(pathTokens, entry.pathTokens);
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures);
const resolved = toResolvedPlanTarget(entry, matched.captures);
if (!resolved) {
continue;
}
@@ -492,11 +513,11 @@ export function resolveConfigSecretTargetByPath(pathSegments: string[]): Resolve
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(pathSegments, entry.pathTokens);
const matched = matchPathTokens(pathTokens, entry.pathTokens);
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures);
const resolved = toResolvedPlanTarget(entry, matched.captures);
if (!resolved) {
continue;
}
+8
View File
@@ -1,3 +1,5 @@
import type { ConcreteConfigPathSegment } from "../shared/dot-path.js";
/** Config document that owns a registered secret-bearing target. */
export type SecretTargetConfigFile = "openclaw.json" | "auth-profile-store"; // pragma: allowlist secret
/** Storage shape used by a target: inline SecretInput or a sibling `*Ref` field. */
@@ -20,6 +22,8 @@ export type SecretTargetRegistryEntry = {
configFile: SecretTargetConfigFile;
/** Dot-path pattern for the secret-bearing value; `*` captures path segments. */
pathPattern: string;
/** Structured pattern segments preserve literal plugin IDs containing dots. */
pathPatternSegments?: string[];
/** Optional sibling SecretRef path materialized from the same captures as `pathPattern`. */
refPathPattern?: string;
/** Whether the registered value stores a SecretInput directly or via a sibling ref field. */
@@ -49,8 +53,12 @@ export type ResolvedPlanTarget = {
entry: SecretTargetRegistryEntry;
/** Concrete path to the secret-bearing value in the owning config document. */
pathSegments: string[];
/** Internal mutation path preserving the registered record-key versus array-index shape. */
pathTokens: ConcreteConfigPathSegment[];
/** Concrete sibling SecretRef path when `entry.secretShape` is `sibling_ref`. */
refPathSegments?: string[];
/** Internal sibling mutation path preserving captured container shape. */
refPathTokens?: ConcreteConfigPathSegment[];
/** Provider id captured from `pathSegments`, if the registry entry declares one. */
providerId?: string;
/** Account/profile id captured from `pathSegments`, if the registry entry declares one. */
+33
View File
@@ -35,6 +35,39 @@ describe("secret target registry", () => {
expect(targets[0]?.path).toBe(TALK_TEST_PROVIDER_API_KEY_PATH);
});
it("preserves dotted provider header keys during discovery", () => {
const config = {
models: {
providers: {
openai: {
headers: {
"X.Trace": { source: "env", provider: "default", id: "TRACE_HEADER" },
},
request: {
headers: {
"X.Request.Trace": {
source: "env",
provider: "default",
id: "REQUEST_TRACE_HEADER",
},
},
},
},
},
},
} as unknown as OpenClawConfig;
const targets = discoverConfigSecretTargetsByIds(
config,
new Set(["models.providers.*.headers.*", "models.providers.*.request.headers.*"]),
);
expect(targets.map(({ path }) => path).toSorted()).toEqual([
'models.providers.openai.headers["X.Trace"]',
'models.providers.openai.request.headers["X.Request.Trace"]',
]);
});
it("resolves talk realtime provider api key targets", () => {
const target = resolveConfigSecretTargetByPath([
"talk",
+257
View File
@@ -1,3 +1,260 @@
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { parseConfigPathArrayIndex } from "./path-array-index.js";
export type ConcreteConfigPathSegment = string | number;
export type ParsedConcreteConfigPath = {
tokens: ConcreteConfigPathSegment[];
quotedNumericSegments: ReadonlySet<number>;
};
function parseQuotedBracketPathSegment(value: string): unknown {
const quote = value.startsWith('"') ? '"' : "'";
if (quote === '"') {
try {
return JSON.parse(value) as unknown;
} catch {
// Quoted config paths also accept the JSON5 string escapes used by shipped CLI paths.
}
}
if (!value.endsWith(quote)) {
throw new SyntaxError("Unterminated quoted path segment");
}
let normalized = '"';
for (let index = 1; index < value.length - 1; index += 1) {
const character = value[index];
if (character === quote) {
throw new SyntaxError("Unexpected quote in path segment");
}
if (character === '"') {
normalized += '\\"';
continue;
}
if (character === "\\") {
const escaped = value[index + 1];
if (escaped === undefined || index + 1 >= value.length - 1) {
throw new SyntaxError("Unterminated escape in path segment");
}
index += 1;
if (escaped === "\n" || escaped === "\u2028" || escaped === "\u2029") {
continue;
}
if (escaped === "\r") {
if (value[index + 1] === "\n") {
index += 1;
}
continue;
}
if (escaped === "x") {
const hex = value.slice(index + 1, index + 3);
if (!/^[\da-f]{2}$/i.test(hex)) {
throw new SyntaxError("Invalid hexadecimal escape in path segment");
}
normalized += `\\u00${hex}`;
index += 2;
continue;
}
if (escaped === "0") {
if (/\d/.test(value[index + 1] ?? "")) {
throw new SyntaxError("Invalid numeric escape in path segment");
}
normalized += "\\u0000";
continue;
}
if (escaped === "v") {
normalized += "\\u000b";
continue;
}
if (/[1-9]/.test(escaped)) {
throw new SyntaxError("Invalid numeric escape in path segment");
}
if (escaped === "'") {
normalized += "'";
} else if ('"\\/bfnrtu'.includes(escaped)) {
normalized += `\\${escaped}`;
} else {
normalized += escaped;
}
continue;
}
normalized += character;
}
return JSON.parse(`${normalized}"`) as unknown;
}
function parseBracketPathSegment(raw: string, fullPath: string): ConcreteConfigPathSegment {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error(`Invalid path (empty "[]"): ${fullPath}`);
}
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
try {
const parsed = parseQuotedBracketPathSegment(trimmed);
if (typeof parsed === "string" && parsed.trim()) {
return parsed;
}
} catch (err) {
throw new Error(`Invalid path bracket string (${trimmed}): ${fullPath}`, { cause: err });
}
throw new Error(`Invalid path bracket string (${trimmed}): ${fullPath}`);
}
return parseConfigPathArrayIndex(trimmed) ?? trimmed;
}
function assertNotWhitespaceSegment(current: string, raw: string): void {
if (current.length > 0 && !current.trim()) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
}
function findBracketPathClose(path: string, open: number): number {
let quote: '"' | "'" | undefined;
for (let index = open + 1; index < path.length; index += 1) {
const character = path[index];
if (quote) {
if (character === "\\") {
index += 1;
} else if (character === quote) {
quote = undefined;
}
continue;
}
if (character === "]") {
return index;
}
if ((character === '"' || character === "'") && !path.slice(open + 1, index).trim()) {
quote = character;
}
}
return -1;
}
/** Retains quoted numeric-key provenance alongside the public concrete path tokens. */
export function parseConcreteConfigPathWithProvenance(raw: string): ParsedConcreteConfigPath {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Path is empty.");
}
const parts: ConcreteConfigPathSegment[] = [];
const quotedNumericSegments = new Set<number>();
let current = "";
let segmentEmitted = false;
let index = 0;
while (index < trimmed.length) {
const character = trimmed[index];
if (character === "\\") {
const next = trimmed[index + 1];
if (next === undefined) {
throw new Error(`Invalid path (trailing escape): ${raw}`);
}
current += next;
index += 2;
continue;
}
if (character === ".") {
assertNotWhitespaceSegment(current, raw);
if (!segmentEmitted && !current.trim()) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current.trim());
}
current = "";
segmentEmitted = false;
index += 1;
continue;
}
if (character === "[") {
assertNotWhitespaceSegment(current, raw);
if (!current.trim() && !segmentEmitted && parts.length > 0) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current.trim());
}
current = "";
const close = findBracketPathClose(trimmed, index);
if (close === -1) {
throw new Error(`Invalid path (missing "]"): ${raw}`);
}
const inside = trimmed.slice(index + 1, close).trim();
if (!inside) {
throw new Error(`Invalid path (empty "[]"): ${raw}`);
}
const segment = parseBracketPathSegment(inside, raw);
if (
typeof segment === "string" &&
(inside.startsWith('"') || inside.startsWith("'")) &&
parseConfigPathArrayIndex(segment) !== undefined
) {
quotedNumericSegments.add(parts.length);
}
parts.push(segment);
const next = trimmed[close + 1];
if (next !== undefined && next !== "." && next !== "[") {
throw new Error(`Invalid path (missing separator after bracket): ${raw}`);
}
segmentEmitted = true;
index = close + 1;
continue;
}
current += character;
index += 1;
}
if (!segmentEmitted && !current.trim()) {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current.trim());
}
for (const segment of parts) {
if (typeof segment === "string" && isBlockedObjectKey(segment)) {
throw new Error(`Invalid path segment: ${segment}`);
}
}
return { tokens: parts, quotedNumericSegments };
}
/** Parses one concrete path while keeping explicit array brackets distinct from quoted keys. */
export function parseConcreteConfigPathTokens(raw: string): ConcreteConfigPathSegment[] {
return parseConcreteConfigPathWithProvenance(raw).tokens;
}
/** Parses one concrete config path into the existing string-segment CLI contract. */
export function parseConcreteConfigPath(raw: string): string[] {
return parseConcreteConfigPathTokens(raw).map(String);
}
/** Appends one config path segment without confusing literal record keys with traversal. */
export function appendConfigPathSegment(path: string, segment: string | number): string {
if (typeof segment === "number") {
return `${path}[${segment}]`;
}
if (!/^[A-Za-z_$][A-Za-z0-9_$:-]*$/.test(segment)) {
return `${path}[${JSON.stringify(segment)}]`;
}
return path ? `${path}.${segment}` : segment;
}
/** Formats concrete tokens, recovering array indices from their actual source containers. */
export function formatConcreteConfigPath(
segments: readonly ConcreteConfigPathSegment[],
source?: unknown,
): string {
let cursor = source;
return segments.reduce<string>((path, segment) => {
const concreteSegment =
typeof segment === "string" && Array.isArray(cursor)
? (parseConfigPathArrayIndex(segment) ?? segment)
: segment;
cursor =
cursor !== null && typeof cursor === "object"
? Reflect.get(cursor, String(segment))
: undefined;
return appendConfigPathSegment(path, concreteSegment);
}, "");
}
/** Joins path segments into their dotted-path representation. */
export function toDotPath(segments: readonly string[]): string {
return segments.join(".");
+80 -1
View File
@@ -357,6 +357,85 @@ describe("setupPluginConfig", () => {
expect(result.plugins?.entries?.brave?.config?.["webSearch.mode"]).toBeUndefined();
});
it.each([
{
name: "an existing array through a dotted index",
field: "accounts.0.token",
existing: { accounts: [{}] },
expected: { accounts: [{ token: "configured" }] },
},
{
name: "a missing schema-declared array through a dotted index",
field: "accounts.0.token",
schema: {
type: "object",
properties: {
accounts: {
type: "array",
items: { type: "object", properties: { token: { type: "string" } } },
},
},
},
expected: { accounts: [{ token: "configured" }] },
},
{
name: "a numeric record key through a dotted path",
field: "accounts.0.token",
schema: {
type: "object",
properties: {
accounts: {
type: "object",
properties: {
"0": { type: "object", properties: { token: { type: "string" } } },
},
},
},
},
expected: { accounts: { "0": { token: "configured" } } },
},
{
name: "an explicit bracketed array index without a schema",
field: "accounts[0].token",
expected: { accounts: [{ token: "configured" }] },
},
{
name: "an explicitly quoted numeric record key without a schema",
field: 'accounts["0"].token',
expected: { accounts: { "0": { token: "configured" } } },
},
{
name: "a quoted record key containing a literal dot",
field: 'accounts["primary.backup"].token',
expected: { accounts: { "primary.backup": { token: "configured" } } },
},
])("writes $name", async ({ field, existing, schema, expected }) => {
const pluginId = "indexed-plugin";
loadPluginManifestRegistryCore.mockReturnValue({
plugins: [makeManifestPlugin(pluginId, { [field]: { label: "Token" } }, schema)],
});
const result = await setupPluginConfig({
config: {
plugins: {
entries: { [pluginId]: { enabled: true, ...(existing && { config: existing }) } },
},
},
prompter: {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: vi.fn(async () => "") as unknown as WizardPrompter["select"],
multiselect: vi.fn(async () => [pluginId]) as unknown as WizardPrompter["multiselect"],
text: vi.fn(async () => "configured") as unknown as WizardPrompter["text"],
confirm: vi.fn(async () => true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
},
});
expect(result.plugins?.entries?.[pluginId]?.config).toEqual(expected);
});
it("rejects prototype-polluting dotted uiHint paths without mutating config", async () => {
const pollutionProbe = "openclawPluginPollutionProbe";
loadPluginManifestRegistryCore.mockReturnValue({
@@ -389,7 +468,7 @@ describe("setupPluginConfig", () => {
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
},
}),
).rejects.toThrow(/prototype-polluting/);
).rejects.toThrow(/Invalid path segment/);
expect(config.plugins?.entries?.["unsafe-plugin"]?.config).toBeUndefined();
expect(({} as Record<string, unknown>)[pollutionProbe]).toBeUndefined();
});
+43 -14
View File
@@ -4,8 +4,13 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginConfigUiHint } from "../plugins/types.js";
import { getPath, setPathCreateStrict } from "../secrets/path-utils.js";
import {
parseConcreteConfigPathTokens,
type ConcreteConfigPathSegment,
} from "../shared/dot-path.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";
@@ -33,21 +38,24 @@ type JsonSchemaProperty = {
function resolveJsonSchemaProperty(
jsonSchema: JsonSchemaObject | undefined,
fieldKey: string,
pathSegments: readonly ConcreteConfigPathSegment[],
): JsonSchemaProperty | undefined {
if (!jsonSchema) {
return undefined;
}
let cursor: unknown = jsonSchema;
for (const segment of fieldKey.split(".")) {
for (const segment of pathSegments) {
if (!cursor || typeof cursor !== "object") {
return undefined;
}
const properties = (cursor as Record<string, unknown>).properties;
if (!properties || typeof properties !== "object") {
return undefined;
}
cursor = (properties as Record<string, unknown>)[segment];
const schema = cursor as Record<string, unknown>;
const properties = schema.properties;
cursor =
schema.type === "array"
? schema.items
: properties && typeof properties === "object"
? (properties as Record<string, unknown>)[String(segment)]
: undefined;
}
return cursor && typeof cursor === "object" ? (cursor as JsonSchemaProperty) : undefined;
}
@@ -59,8 +67,29 @@ function getExistingPluginConfig(
return (config.plugins?.entries?.[pluginId]?.config as Record<string, unknown>) ?? {};
}
function toPathSegments(fieldKey: string): string[] {
return fieldKey.split(".").filter(Boolean);
function toPathSegments(
fieldKey: string,
existing: Record<string, unknown>,
jsonSchema?: JsonSchemaObject,
): ConcreteConfigPathSegment[] {
const segments = parseConcreteConfigPathTokens(fieldKey);
let value: unknown = existing;
return segments.map((segment, index) => {
const schema = resolveJsonSchemaProperty(jsonSchema, segments.slice(0, index));
// Existing containers own their shape; the schema recovers arrays not created yet.
const arrayContainer = Array.isArray(value) || (value == null && schema?.type === "array");
const arrayIndex =
typeof segment === "string" && arrayContainer
? parseConfigPathArrayIndex(segment)
: undefined;
const resolved = arrayIndex ?? segment;
value =
value !== null && typeof value === "object"
? Reflect.get(value, String(resolved))
: undefined;
return resolved;
});
}
function formatCurrentValue(value: unknown): string {
@@ -144,7 +173,7 @@ export function discoverUnconfiguredPlugins(params: {
return all.filter((plugin) => {
const existing = getExistingPluginConfig(params.config, plugin.id);
return Object.keys(plugin.uiHints).some((key) => {
const val = getPath(existing, toPathSegments(key));
const val = getPath(existing, toPathSegments(key, existing, plugin.jsonSchema).map(String));
return val === undefined || val === null || val === "";
});
});
@@ -183,8 +212,8 @@ async function promptPluginFields(params: {
let changed = false;
for (const [key, hint] of Object.entries(plugin.uiHints)) {
const pathSegments = toPathSegments(key);
const currentValue = getPath(existing, pathSegments);
const pathSegments = toPathSegments(key, existing, plugin.jsonSchema);
const currentValue = getPath(existing, pathSegments.map(String));
const hasValue = currentValue !== undefined && currentValue !== null && currentValue !== "";
// In onboard mode, skip already-configured fields
@@ -192,7 +221,7 @@ async function promptPluginFields(params: {
continue;
}
const schemaProp = resolveJsonSchemaProperty(plugin.jsonSchema, key);
const schemaProp = resolveJsonSchemaProperty(plugin.jsonSchema, pathSegments);
const label = hint.label ?? key;
const helpSuffix = hint.help ? `${hint.help}` : "";
@@ -410,7 +439,7 @@ export async function configurePluginConfig(params: {
...configurable.map((p) => {
const existing = getExistingPluginConfig(params.config, p.id);
const configuredCount = Object.keys(p.uiHints).filter((k) => {
const val = getPath(existing, toPathSegments(k));
const val = getPath(existing, toPathSegments(k, existing, p.jsonSchema).map(String));
return val !== undefined && val !== null && val !== "";
}).length;
const totalCount = Object.keys(p.uiHints).length;