refactor(cli): split config command implementation (#113248)

* refactor(cli): split config command implementation

* refactor(cli): keep split helpers private

* style(cli): format private unset result
This commit is contained in:
Peter Steinberger
2026-07-24 00:27:00 -07:00
committed by GitHub
parent 6dbe278283
commit eac10a01d5
7 changed files with 2329 additions and 2473 deletions
-1
View File
@@ -618,7 +618,6 @@ src/cli/command-secret-gateway.test.ts
src/cli/command-secret-gateway.ts
src/cli/command-secret-targets.ts
src/cli/config-cli.test.ts
src/cli/config-cli.ts
src/cli/cron-cli.test.ts
src/cli/daemon-cli/status.gather.test.ts
src/cli/daemon-cli/status.gather.ts
+657
View File
@@ -0,0 +1,657 @@
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import JSON5 from "json5";
import {
coerceSecretRef,
isValidEnvSecretRefId,
type SecretProviderConfig,
type SecretRef,
type SecretRefSource,
} from "../config/types.secrets.js";
import { SecretProviderSchema } from "../config/zod-schema.core.js";
import { hasErrnoCode } from "../infra/errors.js";
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
import {
formatExecSecretRefIdValidationMessage,
isValidFileSecretRefId,
isValidSecretProviderAlias,
validateExecSecretRefId,
} from "../secrets/ref-contract.js";
import { resolveConfigSecretTargetByPath } from "../secrets/target-registry.js";
import { formatCliCommand } from "./command-format.js";
import {
parseConfigSetPath,
parseConfigSetValue,
type PathSegment,
toDotPath,
validatePathSegments,
} from "./config-cli-path.js";
import type { ConfigSetDryRunInputMode, ConfigSetDryRunResult } from "./config-set-dryrun.js";
import {
hasProviderBuilderOptions,
hasRefBuilderOptions,
readConfigMutationFileSync,
type ConfigSetBatchEntry,
type ConfigSetOptions,
} from "./config-set-input.js";
import { resolveConfigSetMode } from "./config-set-parser.js";
const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"];
const CONFIG_PATCH_STDIN_MAX_BYTES = 1024 * 1024;
export type ConfigSetOperation = {
inputMode: ConfigSetDryRunInputMode;
requestedPath: PathSegment[];
setPath: PathSegment[];
value: unknown;
mutation?: "set" | "merge" | "replace" | "delete";
schemaValidated?: boolean;
touchesAllSecretRefs?: boolean;
touchedSecretTargetPath?: string;
touchedProviderAlias?: string;
assignedRef?: SecretRef;
};
export type ConfigPatchOptions = {
file?: string;
stdin?: boolean;
dryRun?: boolean;
allowExec?: boolean;
json?: boolean;
replacePath?: string[];
};
export type ConfigUnsetOptions = {
dryRun?: boolean;
allowExec?: boolean;
json?: boolean;
};
export type ConfigMutationOptions = ConfigUnsetOptions & {
merge?: boolean;
replace?: boolean;
};
export class ConfigSetDryRunValidationError extends Error {
constructor(readonly result: ConfigSetDryRunResult) {
super("config set dry-run validation failed");
this.name = "ConfigSetDryRunValidationError";
}
}
export function modeError(message: string): Error {
return new Error(`config set mode error: ${message}`);
}
export function configPatchModeError(message: string): Error {
return new Error(`config patch mode error: ${message}`);
}
function parseSecretRefSource(raw: string, label: string): SecretRefSource {
const source = raw.trim();
if (source === "env" || source === "file" || source === "exec") {
return source;
}
throw new Error(`${label} must be one of: env, file, exec.`);
}
function parseSecretRefBuilder(params: {
provider: string;
source: string;
id: string;
fieldPrefix: string;
}): SecretRef {
const provider = params.provider.trim();
if (!provider) {
throw new Error(`${params.fieldPrefix}.provider is required.`);
}
if (!isValidSecretProviderAlias(provider)) {
throw new Error(
`${params.fieldPrefix}.provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`,
);
}
const source = parseSecretRefSource(params.source, `${params.fieldPrefix}.source`);
const id = params.id.trim();
if (!id) {
throw new Error(`${params.fieldPrefix}.id is required.`);
}
if (source === "env" && !isValidEnvSecretRefId(id)) {
throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`);
}
if (source === "file" && !isValidFileSecretRefId(id)) {
throw new Error(
`${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`,
);
}
if (source === "exec" && !validateExecSecretRefId(id).ok) {
throw new Error(formatExecSecretRefIdValidationMessage());
}
return { source, provider, id };
}
function parseOptionalPositiveInteger(raw: string | undefined, flag: string): number | undefined {
if (raw === undefined) {
return undefined;
}
const trimmed = raw.trim();
if (!trimmed) {
throw new Error(`${flag} must not be empty.`);
}
const parsed = parseStrictPositiveInteger(trimmed);
if (parsed === undefined) {
throw new Error(`${flag} must be a positive integer.`);
}
return parsed;
}
function parseProviderEnvEntries(
entries: string[] | undefined,
): Record<string, string> | undefined {
if (!entries || entries.length === 0) {
return undefined;
}
const env: Record<string, string> = {};
for (const entry of entries) {
const separator = entry.indexOf("=");
if (separator <= 0) {
throw new Error(`--provider-env expects KEY=VALUE entries (received: "${entry}").`);
}
const key = entry.slice(0, separator).trim();
if (!key) {
throw new Error(`--provider-env key must not be empty (received: "${entry}").`);
}
env[key] = entry.slice(separator + 1);
}
return Object.keys(env).length > 0 ? env : undefined;
}
function parseProviderAliasPath(path: PathSegment[]): string {
if (
path.length !== 3 ||
path[0] !== SECRET_PROVIDER_PATH_PREFIX[0] ||
path[1] !== SECRET_PROVIDER_PATH_PREFIX[1]
) {
throw new Error(
'Provider builder mode requires path "secrets.providers.<alias>" (example: secrets.providers.vault).',
);
}
const alias = path[2] ?? "";
if (!isValidSecretProviderAlias(alias)) {
throw new Error(
`Provider alias "${alias}" must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`,
);
}
return alias;
}
function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig {
const sourceRaw = opts.providerSource?.trim();
if (!sourceRaw) {
throw new Error("--provider-source is required in provider builder mode.");
}
const source = parseSecretRefSource(sourceRaw, "--provider-source");
const timeoutMs = parseOptionalPositiveInteger(opts.providerTimeoutMs, "--provider-timeout-ms");
const maxBytes = parseOptionalPositiveInteger(opts.providerMaxBytes, "--provider-max-bytes");
const noOutputTimeoutMs = parseOptionalPositiveInteger(
opts.providerNoOutputTimeoutMs,
"--provider-no-output-timeout-ms",
);
const maxOutputBytes = parseOptionalPositiveInteger(
opts.providerMaxOutputBytes,
"--provider-max-output-bytes",
);
const providerEnv = parseProviderEnvEntries(opts.providerEnv);
let provider: SecretProviderConfig;
if (source === "env") {
const allowlist = normalizeStringEntries(opts.providerAllowlist);
for (const envName of allowlist) {
if (!isValidEnvSecretRefId(envName)) {
throw new Error(
`--provider-allowlist entry "${envName}" must match /^[A-Z][A-Z0-9_]{0,127}$/.`,
);
}
}
provider = { source: "env", ...(allowlist.length > 0 ? { allowlist } : {}) };
} else if (source === "file") {
const filePath = opts.providerPath?.trim();
if (!filePath) {
throw new Error("--provider-path is required when --provider-source file is used.");
}
const modeRaw = opts.providerMode?.trim();
if (modeRaw && modeRaw !== "singleValue" && modeRaw !== "json") {
throw new Error("--provider-mode must be one of: singleValue, json.");
}
const mode = modeRaw === "singleValue" || modeRaw === "json" ? modeRaw : undefined;
provider = {
source: "file",
path: filePath,
...(mode ? { mode } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(maxBytes !== undefined ? { maxBytes } : {}),
...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}),
};
} else {
const command = opts.providerCommand?.trim();
if (!command) {
throw new Error("--provider-command is required when --provider-source exec is used.");
}
provider = {
source: "exec",
command,
...(opts.providerArg?.length ? { args: opts.providerArg.map((entry) => entry.trim()) } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}),
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
...(opts.providerJsonOnly ? { jsonOnly: true } : {}),
...(providerEnv ? { env: providerEnv } : {}),
...(opts.providerPassEnv?.length
? { passEnv: normalizeStringEntries(opts.providerPassEnv) }
: {}),
...(opts.providerTrustedDir?.length
? { trustedDirs: normalizeStringEntries(opts.providerTrustedDir) }
: {}),
...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}),
...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}),
};
}
const validated = SecretProviderSchema.safeParse(provider);
if (!validated.success) {
const issue = validated.error.issues[0];
throw new Error(
`Provider builder config invalid at ${issue?.path?.join(".") ?? "<provider>"}: ${issue?.message ?? "Invalid provider config."}`,
);
}
return validated.data;
}
function parseSecretRefFromUnknown(value: unknown, label: string): SecretRef {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object with source/provider/id.`);
}
const candidate = value as Record<string, unknown>;
if (
typeof candidate.provider !== "string" ||
typeof candidate.source !== "string" ||
typeof candidate.id !== "string"
) {
throw new Error(`${label} must include string fields: source, provider, id.`);
}
return parseSecretRefBuilder({
provider: candidate.provider,
source: candidate.source,
id: candidate.id,
fieldPrefix: label,
});
}
function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null {
return path.length >= 3 && path[0] === "secrets" && path[1] === "providers"
? (path[2] ?? null)
: null;
}
function touchesSecretProviderCollection(path: PathSegment[]): boolean {
return (
(path.length === 1 && path[0] === "secrets") ||
(path.length === 2 && path[0] === "secrets" && path[1] === "providers")
);
}
function touchesSecretDefaults(path: PathSegment[]): boolean {
return (
(path.length === 1 && path[0] === "secrets") ||
(path.length === 2 && path[0] === "secrets" && path[1] === "defaults")
);
}
function buildRefAssignmentOperation(params: {
requestedPath: PathSegment[];
ref: SecretRef;
inputMode: ConfigSetDryRunInputMode;
}): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) {
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: resolved.refPathSegments,
value: params.ref,
schemaValidated: true,
touchedSecretTargetPath: toDotPath(resolved.pathSegments),
assignedRef: params.ref,
...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
};
}
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: params.requestedPath,
value: params.ref,
...(resolved ? { schemaValidated: true } : {}),
touchedSecretTargetPath: toDotPath(resolved?.pathSegments ?? params.requestedPath),
assignedRef: params.ref,
...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
};
}
function buildValueAssignmentOperation(params: {
requestedPath: PathSegment[];
value: unknown;
inputMode: ConfigSetDryRunInputMode;
}): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath);
const coercedRef = coerceSecretRef(params.value);
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: params.requestedPath,
value: params.value,
...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}),
...(providerAlias ? { touchedProviderAlias: providerAlias } : {}),
...(coercedRef ? { assignedRef: coercedRef } : {}),
};
}
function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] {
return entries.map((entry, index) => {
const path = parseConfigSetPath(entry.path);
if (entry.ref !== undefined) {
return buildRefAssignmentOperation({
requestedPath: path,
ref: parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`),
inputMode: "json",
});
}
if (entry.provider !== undefined) {
const alias = parseProviderAliasPath(path);
const validated = SecretProviderSchema.safeParse(entry.provider);
if (!validated.success) {
const issue = validated.error.issues[0];
throw new Error(
`batch[${index}].provider invalid at ${issue?.path?.join(".") ?? "<provider>"}: ${issue?.message ?? ""}`,
);
}
return {
inputMode: "json",
requestedPath: path,
setPath: path,
value: validated.data,
schemaValidated: true,
touchedProviderAlias: alias,
};
}
return buildValueAssignmentOperation({
requestedPath: path,
value: entry.value,
inputMode: "json",
});
});
}
function buildSingleSetOperations(params: {
path?: string;
value?: string;
opts: ConfigSetOptions;
}): ConfigSetOperation[] {
const pathProvided = typeof params.path === "string" && params.path.trim().length > 0;
const parsedPath = pathProvided ? parseConfigSetPath(params.path as string) : null;
const strictJson = Boolean(params.opts.strictJson || params.opts.json);
const modeResolution = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: hasRefBuilderOptions(params.opts),
hasProviderBuilderOptions: hasProviderBuilderOptions(params.opts),
strictJson,
});
if (!modeResolution.ok) {
throw modeError(modeResolution.error);
}
if (modeResolution.mode === "ref_builder") {
if (!pathProvided || !parsedPath) {
throw modeError("ref builder mode requires <path>.");
}
if (params.value !== undefined) {
throw modeError("ref builder mode does not accept <value>.");
}
if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) {
throw modeError(
"ref builder mode requires --ref-provider <alias>, --ref-source <env|file|exec>, and --ref-id <id>.",
);
}
return [
buildRefAssignmentOperation({
requestedPath: parsedPath,
ref: parseSecretRefBuilder({
provider: params.opts.refProvider,
source: params.opts.refSource,
id: params.opts.refId,
fieldPrefix: "ref",
}),
inputMode: "builder",
}),
];
}
if (modeResolution.mode === "provider_builder") {
if (!pathProvided || !parsedPath) {
throw modeError("provider builder mode requires <path>.");
}
if (params.value !== undefined) {
throw modeError("provider builder mode does not accept <value>.");
}
return [
{
inputMode: "builder",
requestedPath: parsedPath,
setPath: parsedPath,
value: buildProviderFromBuilder(params.opts),
schemaValidated: true,
touchedProviderAlias: parseProviderAliasPath(parsedPath),
},
];
}
if (!pathProvided || !parsedPath) {
throw modeError("value/json mode requires <path> when batch mode is not used.");
}
if (params.value === undefined) {
throw modeError("value/json mode requires <value>.");
}
return [
buildValueAssignmentOperation({
requestedPath: parsedPath,
value: parseConfigSetValue(params.value, strictJson),
inputMode: modeResolution.mode === "json" ? "json" : "value",
}),
];
}
export function buildConfigSetOperations(params: {
path?: string;
value?: string;
opts: ConfigSetOptions;
batchEntries: ConfigSetBatchEntry[] | null;
}): ConfigSetOperation[] {
return params.batchEntries
? parseBatchOperations(params.batchEntries)
: buildSingleSetOperations(params);
}
async function readStdinText(): Promise<string> {
if (process.stdin.isTTY) {
throw configPatchModeError(
"--stdin refuses to read from an interactive terminal; pipe input or use --file <path>.",
);
}
process.stdin.setEncoding("utf8");
const bytes = await readByteStreamWithLimit(process.stdin, {
maxBytes: CONFIG_PATCH_STDIN_MAX_BYTES,
onOverflow: ({ maxBytes }) =>
configPatchModeError(
`--stdin input exceeds ${maxBytes} bytes; use --file <path> for larger patches.`,
),
});
return bytes.toString("utf8");
}
async function readConfigPatchInput(opts: ConfigPatchOptions): Promise<unknown> {
const file = normalizeOptionalString(opts.file);
const stdin = Boolean(opts.stdin);
if (Boolean(file) === stdin) {
throw configPatchModeError("provide exactly one of --file <path> or --stdin.");
}
const sourceLabel = stdin ? "--stdin" : "--file";
let raw: string;
if (stdin) {
raw = await readStdinText();
} else {
try {
raw = readConfigMutationFileSync(file as string, "--file");
} catch (err) {
if (hasErrnoCode(err, "ENOENT")) {
throw new Error(`--file not found: ${file}`, { cause: err });
}
throw err;
}
}
try {
return JSON5.parse(raw);
} catch (err) {
throw new Error(`Failed to parse ${sourceLabel} as JSON5: ${String(err)}`, { cause: err });
}
}
function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation {
return {
inputMode: "json",
requestedPath: path,
setPath: path,
value: undefined,
mutation: "delete",
};
}
export function buildUnsetOperation(path: PathSegment[]): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(path);
const providerAlias = parseProviderAliasFromTargetPath(path);
return {
inputMode: "unset",
requestedPath: path,
setPath: path,
value: undefined,
mutation: "delete",
...(touchesSecretProviderCollection(path) || touchesSecretDefaults(path)
? { touchesAllSecretRefs: true }
: {}),
...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}),
...(providerAlias ? { touchedProviderAlias: providerAlias } : {}),
};
}
function buildApplyValueOperation(params: {
path: PathSegment[];
value: unknown;
mutation?: ConfigSetOperation["mutation"];
}): ConfigSetOperation {
const ref = isPlainRecord(params.value) ? coerceSecretRef(params.value) : null;
const operation = ref
? buildRefAssignmentOperation({
requestedPath: params.path,
ref: parseSecretRefFromUnknown(params.value, `patch.${toDotPath(params.path)}`),
inputMode: "json",
})
: buildValueAssignmentOperation({
requestedPath: params.path,
value: params.value,
inputMode: "json",
});
return { ...operation, ...(params.mutation ? { mutation: params.mutation } : {}) };
}
function buildConfigPatchOperations(params: {
patch: unknown;
replacePaths: PathSegment[][];
}): ConfigSetOperation[] {
if (!isPlainRecord(params.patch)) {
throw configPatchModeError("input must be a JSON5 object patch.");
}
const operations: ConfigSetOperation[] = [];
const pathKey = (path: PathSegment[]) => JSON.stringify(path);
const replacePathKeys = new Set(params.replacePaths.map(pathKey));
const matchedReplacePathKeys = new Set<string>();
const visit = (value: unknown, path: PathSegment[]) => {
validatePathSegments(path);
const replacementKey = pathKey(path);
if (path.length > 0 && replacePathKeys.has(replacementKey)) {
matchedReplacePathKeys.add(replacementKey);
operations.push(
value === null
? buildDeleteOperation(path)
: buildApplyValueOperation({ path, value, mutation: "replace" }),
);
return;
}
if (path.length > 0 && value === null) {
operations.push(buildDeleteOperation(path));
return;
}
if (path.length > 0 && isPlainRecord(value) && coerceSecretRef(value)) {
operations.push(buildApplyValueOperation({ path, value }));
return;
}
if (isPlainRecord(value)) {
if (path.length > 0 && Object.keys(value).length === 0) {
operations.push(buildApplyValueOperation({ path, value, mutation: "merge" }));
return;
}
for (const [key, child] of Object.entries(value)) {
visit(child, [...path, key]);
}
return;
}
if (path.length === 0) {
throw configPatchModeError("input must contain at least one config key.");
}
operations.push(buildApplyValueOperation({ path, value }));
};
visit(params.patch, []);
const unusedReplacePath = params.replacePaths.find(
(path) => !matchedReplacePathKeys.has(pathKey(path)),
);
if (unusedReplacePath) {
throw configPatchModeError(
`--replace-path ${toDotPath(unusedReplacePath)} did not match any value in the input patch.`,
);
}
if (operations.length === 0) {
throw configPatchModeError("input patch did not contain any config updates.");
}
return operations;
}
export async function readConfigPatchOperations(
opts: ConfigPatchOptions,
): Promise<ConfigSetOperation[]> {
return buildConfigPatchOperations({
patch: await readConfigPatchInput(opts),
replacePaths: (opts.replacePath ?? []).map(parseConfigSetPath),
});
}
export function formatPluginInstallConfigSetError(): string {
return [
"plugins.installs is managed by the plugin index and cannot be edited with config set.",
"",
"Use plugin commands instead:",
` ${formatCliCommand("openclaw plugins install <spec>")}`,
` ${formatCliCommand("openclaw plugins update <plugin-id>")}`,
` ${formatCliCommand("openclaw plugins uninstall <plugin-id>")}`,
].join("\n");
}
+164
View File
@@ -0,0 +1,164 @@
import { expectDefined } from "@openclaw/normalization-core";
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js";
import {
normalizeAgentModelMapForConfig,
normalizeAgentModelRefForConfig,
} from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PathSegment } from "./config-cli-path.js";
function normalizeAgentDefaultModelValue(value: unknown): unknown {
if (typeof value === "string") {
return normalizeAgentModelRefForConfig(value);
}
if (!isPlainRecord(value)) {
return value;
}
const next: Record<string, unknown> = { ...value };
if (typeof next.primary === "string") {
next.primary = normalizeAgentModelRefForConfig(next.primary);
}
if (Array.isArray(next.fallbacks)) {
next.fallbacks = next.fallbacks.map((fallback) =>
typeof fallback === "string" ? normalizeAgentModelRefForConfig(fallback) : fallback,
);
}
return next;
}
function normalizeAgentListModelRefs(value: unknown): unknown {
if (!Array.isArray(value)) {
return value;
}
let mutated = false;
const next = value.map((agent) => {
if (!isPlainRecord(agent)) {
return agent;
}
let nextAgent = agent;
if (Object.hasOwn(agent, "model")) {
const model = normalizeAgentDefaultModelValue(agent.model);
if (model !== agent.model) {
nextAgent = { ...nextAgent, model };
mutated = true;
}
}
if (isPlainRecord(agent.models)) {
const models = normalizeAgentModelMapForConfig(agent.models);
if (models !== agent.models) {
nextAgent = { ...nextAgent, models };
mutated = true;
}
}
return nextAgent;
});
return mutated ? next : value;
}
function normalizeProviderCatalogModels(provider: string, models: unknown): unknown {
if (!Array.isArray(models)) {
return models;
}
let mutated = false;
const next = models.map((model) => {
if (!isPlainRecord(model) || typeof model.id !== "string") {
return model;
}
const trimmed = model.id.trim();
if (!trimmed) {
return model;
}
const id = normalizeConfiguredProviderCatalogModelId(provider, trimmed);
if (id === model.id) {
return model;
}
mutated = true;
return { ...model, id };
});
return mutated ? next : models;
}
function normalizeModelProviderRefs(
providers: NonNullable<OpenClawConfig["models"]>["providers"] | undefined,
): unknown {
if (!isPlainRecord(providers)) {
return providers;
}
let mutated = false;
const nextProviders: Record<string, unknown> = { ...providers };
for (const [provider, providerConfig] of Object.entries(providers)) {
if (!isPlainRecord(providerConfig)) {
continue;
}
const models = normalizeProviderCatalogModels(provider, providerConfig.models);
if (models === providerConfig.models) {
continue;
}
nextProviders[provider] = { ...providerConfig, models };
mutated = true;
}
return mutated ? nextProviders : providers;
}
export function normalizeConfigMutationModelRefs(cfg: OpenClawConfig): OpenClawConfig {
const defaults = cfg.agents?.defaults;
const agentList = cfg.agents?.list;
const providers = cfg.models?.providers;
const normalizedAgentList = normalizeAgentListModelRefs(agentList);
const normalizedProviders = normalizeModelProviderRefs(providers) as typeof providers | undefined;
return {
...cfg,
...(defaults || normalizedAgentList !== agentList
? {
agents: {
...cfg.agents,
...(defaults
? {
defaults: {
...defaults,
...(defaults.model !== undefined
? {
model: normalizeAgentDefaultModelValue(
defaults.model,
) as typeof defaults.model,
}
: undefined),
...(defaults.models !== undefined
? { models: normalizeAgentModelMapForConfig(defaults.models) }
: undefined),
},
}
: undefined),
...(normalizedAgentList !== agentList
? { list: normalizedAgentList as typeof agentList }
: undefined),
},
}
: undefined),
...(normalizedProviders !== providers
? { models: { ...cfg.models, providers: normalizedProviders } }
: undefined),
};
}
export function normalizeConfigMutationExplicitSetPath(path: PathSegment[]): PathSegment[] {
if (path.length >= 4 && path[0] === "agents" && path[1] === "defaults" && path[2] === "models") {
const normalizedModelId = normalizeAgentModelRefForConfig(
expectDefined(path[3], "path entry at 3"),
);
return normalizedModelId === path[3]
? path
: [...path.slice(0, 3), normalizedModelId, ...path.slice(4)];
}
return path;
}
+578
View File
@@ -0,0 +1,578 @@
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import JSON5 from "json5";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
import { formatCliCommand } from "./command-format.js";
import { formatStrictJsonParseFailure } from "./error-format.js";
export type PathSegment = string;
export type JsonSchemaRecord = {
type?: unknown;
properties?: unknown;
additionalProperties?: unknown;
items?: unknown;
anyOf?: unknown;
oneOf?: unknown;
allOf?: unknown;
};
type SetAtPathOptions = {
numericObjectKeys?: boolean;
schema?: JsonSchemaRecord;
};
function parseIndexSegment(raw: string): number | undefined {
return parseConfigPathArrayIndex(raw);
}
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 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) {
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);
}
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);
}
current = "";
const close = trimmed.indexOf("]", 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);
}
return normalizeStringEntries(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) {
try {
return JSON.parse(trimmed);
} catch (err) {
throw new Error(formatStrictJsonParseFailure({ value: raw, cause: err }), { cause: err });
}
}
try {
return JSON5.parse(trimmed);
} catch {
return raw;
}
}
export function validatePathSegments(path: PathSegment[]): void {
for (const segment of path) {
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
throw new Error(`Invalid path segment: ${segment}`);
}
}
}
function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
return Object.hasOwn(value, key);
}
export function getAtPath(
root: unknown,
path: readonly PathSegment[],
): { found: boolean; value?: unknown } {
let current: unknown = root;
for (const segment of path) {
if (!current || typeof current !== "object") {
return { found: false };
}
if (Array.isArray(current)) {
const index = parseIndexSegment(segment);
if (index === undefined || index >= current.length) {
return { found: false };
}
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!hasOwnPathKey(record, segment)) {
return { found: false };
}
current = record[segment];
}
return { found: true, value: current };
}
export function formatConfigUnsetMissingPathMessage(params: {
path: string;
runtimeOnly: boolean;
}): string {
if (params.runtimeOnly) {
return `Config path not found in authored config: ${params.path}. It only exists after runtime defaults are applied, so there is nothing for config unset to remove. Use ${formatCliCommand("openclaw config set <path> <value>")} to override the inherited value.`;
}
return `Config path not found: ${params.path}. Nothing was changed. Run ${formatCliCommand("openclaw config get <path>")} first if you are unsure of the path.`;
}
function isSchemaRecord(value: unknown): value is JsonSchemaRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function schemaTypes(schema: JsonSchemaRecord): Set<string> {
if (typeof schema.type === "string") {
return new Set([schema.type]);
}
if (Array.isArray(schema.type)) {
return new Set(schema.type.filter((entry): entry is string => typeof entry === "string"));
}
return new Set();
}
function schemaAlternatives(
schema: JsonSchemaRecord,
seen = new Set<JsonSchemaRecord>(),
): JsonSchemaRecord[] {
if (seen.has(schema)) {
return [];
}
seen.add(schema);
const alternatives: JsonSchemaRecord[] = [schema];
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
const entries = schema[key];
if (!Array.isArray(entries)) {
continue;
}
for (const entry of entries) {
if (isSchemaRecord(entry)) {
alternatives.push(...schemaAlternatives(entry, seen));
}
}
}
return alternatives;
}
function schemaLooksArray(schema: JsonSchemaRecord): boolean {
return (
schemaTypes(schema).has("array") || isSchemaRecord(schema.items) || Array.isArray(schema.items)
);
}
function schemaLooksObject(schema: JsonSchemaRecord): boolean {
const types = schemaTypes(schema);
return (
types.has("object") ||
isSchemaRecord(schema.properties) ||
schema.additionalProperties === true ||
isSchemaRecord(schema.additionalProperties)
);
}
function propertySchema(schema: JsonSchemaRecord, segment: PathSegment): JsonSchemaRecord[] {
const schemas: JsonSchemaRecord[] = [];
for (const alternative of schemaAlternatives(schema)) {
if (schemaLooksArray(alternative)) {
const index = parseIndexSegment(segment);
if (index !== undefined) {
const indexedItem = Array.isArray(alternative.items)
? alternative.items[index]
: alternative.items;
if (isSchemaRecord(indexedItem)) {
schemas.push(indexedItem);
}
}
continue;
}
const properties = isSchemaRecord(alternative.properties)
? (alternative.properties as Record<string, unknown>)
: undefined;
const explicit = properties?.[segment];
if (isSchemaRecord(explicit)) {
schemas.push(explicit);
} else if (isSchemaRecord(alternative.additionalProperties)) {
schemas.push(alternative.additionalProperties);
}
}
return schemas;
}
function schemasAtPath(
schema: JsonSchemaRecord | undefined,
path: readonly PathSegment[],
): JsonSchemaRecord[] {
if (!schema) {
return [];
}
let schemas = [schema];
for (const segment of path) {
schemas = schemas.flatMap((candidate) => propertySchema(candidate, segment));
if (schemas.length === 0) {
return [];
}
}
return schemas;
}
function schemaPrefersArrayAtPath(
schema: JsonSchemaRecord | undefined,
path: readonly PathSegment[],
): boolean | undefined {
const candidates = schemasAtPath(schema, path).flatMap((candidate) =>
schemaAlternatives(candidate),
);
if (candidates.length === 0) {
return undefined;
}
const hasArray = candidates.some((candidate) => schemaLooksArray(candidate));
const hasObject = candidates.some((candidate) => schemaLooksObject(candidate));
if (hasArray && !hasObject) {
return true;
}
if (hasObject && !hasArray) {
return false;
}
return undefined;
}
function shouldCreateArrayForMissingPathSegment(params: {
path: readonly PathSegment[];
segmentIndex: number;
next?: PathSegment;
options?: SetAtPathOptions;
}): boolean {
if (!params.next || params.options?.numericObjectKeys || !isIndexSegment(params.next)) {
return false;
}
const parentPath = params.path.slice(0, params.segmentIndex + 1);
return schemaPrefersArrayAtPath(params.options?.schema, parentPath) ?? true;
}
export function setAtPath(
root: Record<string, unknown>,
path: PathSegment[],
value: unknown,
options?: SetAtPathOptions,
): void {
const last = path.at(-1);
if (last === undefined) {
throw new Error("Config path must contain at least one segment");
}
let current: unknown = root;
for (const [i, segment] of path.slice(0, -1).entries()) {
const nextIsIndex = shouldCreateArrayForMissingPathSegment({
path,
segmentIndex: i,
next: path[i + 1],
options,
});
if (Array.isArray(current)) {
const index = parseIndexSegment(segment);
if (index === undefined) {
throw new Error(`Expected numeric index for array segment "${segment}"`);
}
const existing = current[index];
if (!existing || typeof existing !== "object") {
current[index] = nextIsIndex ? [] : {};
}
current = current[index];
continue;
}
if (!current || typeof current !== "object") {
throw new Error(`Cannot traverse into "${segment}" (not an object)`);
}
const record = current as Record<string, unknown>;
const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined;
if (!existing || typeof existing !== "object") {
record[segment] = nextIsIndex ? [] : {};
}
current = record[segment];
}
if (Array.isArray(current)) {
const index = parseIndexSegment(last);
if (index === undefined) {
throw new Error(`Expected numeric index for array segment "${last}"`);
}
current[index] = value;
return;
}
if (!current || typeof current !== "object") {
throw new Error(`Cannot set "${last}" (parent is not an object)`);
}
(current as Record<string, unknown>)[last] = value;
}
function modelArrayIds(value: unknown): Set<string> | null {
if (!Array.isArray(value)) {
return null;
}
const ids = new Set<string>();
for (const entry of value) {
if (!isPlainRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) {
return null;
}
ids.add(entry.id.trim());
}
return ids;
}
function mergeModelArrays(existing: unknown[], patch: unknown[]): unknown[] {
const merged = [...existing];
const indexById = new Map<string, number>();
for (const [index, entry] of merged.entries()) {
if (isPlainRecord(entry) && typeof entry.id === "string" && entry.id.trim()) {
indexById.set(entry.id.trim(), index);
}
}
for (const entry of patch) {
if (!isPlainRecord(entry) || typeof entry.id !== "string" || !entry.id.trim()) {
merged.push(entry);
continue;
}
const id = entry.id.trim();
const existingIndex = indexById.get(id);
if (existingIndex === undefined) {
indexById.set(id, merged.length);
merged.push(entry);
continue;
}
const existingEntry = merged[existingIndex];
merged[existingIndex] = isPlainRecord(existingEntry) ? { ...existingEntry, ...entry } : entry;
}
return merged;
}
function isProviderModelListPath(path: PathSegment[]): boolean {
return (
path.length === 4 && path[0] === "models" && path[1] === "providers" && path[3] === "models"
);
}
function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[]): unknown {
if (isProviderModelListPath(path) && Array.isArray(existing) && Array.isArray(patch)) {
return mergeModelArrays(existing, patch);
}
if (isPlainRecord(existing) && isPlainRecord(patch)) {
const next: Record<string, unknown> = { ...existing };
for (const [key, value] of Object.entries(patch)) {
next[key] =
hasOwnPathKey(next, key) && isPlainRecord(next[key]) && isPlainRecord(value)
? mergeConfigValue(next[key], value, [...path, key])
: value;
}
return next;
}
throw new Error(`Cannot merge ${toDotPath(path)}; use --replace to replace intentionally.`);
}
export function mergeAtPath(
root: Record<string, unknown>,
path: PathSegment[],
value: unknown,
options?: SetAtPathOptions,
): void {
const existing = getAtPath(root, path);
setAtPath(
root,
path,
existing.found ? mergeConfigValue(existing.value, value, path) : value,
options,
);
}
function isProtectedMapReplacementPath(path: PathSegment[]): boolean {
const joined = path.join(".");
return (
joined === "agents.defaults.models" ||
joined === "models.providers" ||
(path.length === 3 && path[0] === "models" && path[1] === "providers") ||
joined === "plugins.entries" ||
joined === "auth.profiles"
);
}
function isProtectedArrayReplacementPath(path: PathSegment[]): boolean {
return isProviderModelListPath(path) || path.join(".") === "agents.list";
}
function formatRemovedEntries(entries: string[]): string {
const visible = entries.slice(0, 6);
const suffix =
entries.length > visible.length ? `, ... ${entries.length - visible.length} more` : "";
return `${visible.join(", ")}${suffix}`;
}
export function assertNonDestructiveReplacement(params: {
root: Record<string, unknown>;
path: PathSegment[];
value: unknown;
allowReplace?: boolean;
}): void {
if (params.allowReplace) {
return;
}
const existing = getAtPath(params.root, params.path);
if (!existing.found) {
return;
}
const pathLabel = toDotPath(params.path);
if (isProtectedMapReplacementPath(params.path) && isPlainRecord(existing.value)) {
if (!isPlainRecord(params.value)) {
return;
}
const nextKeys = new Set(Object.keys(params.value));
const removed = Object.keys(existing.value).filter((key) => !nextKeys.has(key));
if (removed.length > 0) {
throw new Error(
`Refusing to replace ${pathLabel}; it would remove existing entries: ${formatRemovedEntries(removed)}. Use --merge to merge object values or --replace to replace intentionally.`,
);
}
}
if (isProtectedArrayReplacementPath(params.path)) {
const existingIds = modelArrayIds(existing.value);
const nextIds = modelArrayIds(params.value);
if (!existingIds || !nextIds) {
return;
}
const removed = [...existingIds].filter((id) => !nextIds.has(id));
if (removed.length > 0) {
throw new Error(
`Refusing to replace ${pathLabel}; it would remove existing entries: ${formatRemovedEntries(removed)}. Use --merge to merge by id or --replace to replace intentionally.`,
);
}
}
}
type UnsetAtPathResult = { removed: true; leafContainer: "array" | "object" } | { removed: false };
export function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): UnsetAtPathResult {
const last = path.at(-1);
if (last === undefined) {
return { removed: false };
}
let current: unknown = root;
for (const segment of path.slice(0, -1)) {
if (!current || typeof current !== "object") {
return { removed: false };
}
if (Array.isArray(current)) {
const index = parseIndexSegment(segment);
if (index === undefined || index >= current.length) {
return { removed: false };
}
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!hasOwnPathKey(record, segment)) {
return { removed: false };
}
current = record[segment];
}
if (Array.isArray(current)) {
const index = parseIndexSegment(last);
if (index === undefined || index >= current.length) {
return { removed: false };
}
current.splice(index, 1);
return { removed: true, leafContainer: "array" };
}
if (!current || typeof current !== "object") {
return { removed: false };
}
const record = current as Record<string, unknown>;
if (!hasOwnPathKey(record, last)) {
return { removed: false };
}
delete record[last];
return { removed: true, leafContainer: "object" };
}
export function toDotPath(path: readonly PathSegment[]): string {
return path.join(".");
}
+506
View File
@@ -0,0 +1,506 @@
import { uniqueValues } from "@openclaw/normalization-core/string-normalization";
import { replaceConfigFile } from "../config/config.js";
import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { collectUnsupportedSecretRefPolicyIssues } from "../config/validation.js";
import { diffConfigPaths } from "../gateway/config-diff.js";
import { buildGatewayReloadPlan } from "../gateway/config-reload-plan.js";
import { resolveGatewayReloadSettings } from "../gateway/config-reload-settings.js";
import { danger, info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { writeRuntimeJson } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import {
ConfigSetDryRunValidationError,
formatPluginInstallConfigSetError,
type ConfigMutationOptions,
type ConfigSetOperation,
} from "./config-cli-input.js";
import {
normalizeConfigMutationExplicitSetPath,
normalizeConfigMutationModelRefs,
} from "./config-cli-model-normalization.js";
import {
assertNonDestructiveReplacement,
getAtPath,
mergeAtPath,
setAtPath,
toDotPath,
type JsonSchemaRecord,
type PathSegment,
unsetAtPath,
} from "./config-cli-path.js";
import {
collectDryRunRefs,
collectDryRunResolvabilityErrors,
collectDryRunSchemaErrors,
collectDryRunStaticErrorsForSkippedExecRefs,
collectPluginIntegrationProviderErrors,
dedupeDryRunErrors,
formatDryRunFailureMessage,
loadValidConfig,
selectDryRunRefsForResolution,
} from "./config-cli-validation.js";
import { checkTouchedTextModelRefs } from "./config-model-validation.js";
import type { ConfigSetDryRunError, ConfigSetDryRunResult } from "./config-set-dryrun.js";
const GATEWAY_AUTH_MODE_PATH: PathSegment[] = ["gateway", "auth", "mode"];
const PLUGIN_INSTALL_RECORD_PATH_PREFIX: PathSegment[] = ["plugins", "installs"];
const CONFIG_SET_POLICY_ERROR_MAX_ISSUES = 5;
function pathStartsWith(path: readonly PathSegment[], prefix: readonly PathSegment[]): boolean {
return prefix.every((segment, index) => path[index] === segment);
}
function pathEquals(path: readonly PathSegment[], expected: readonly PathSegment[]): boolean {
return (
path.length === expected.length && path.every((segment, index) => segment === expected[index])
);
}
function valueHasAutoManagedChild(value: unknown, childPath: readonly PathSegment[]): boolean {
let cursor: unknown = value;
for (const segment of childPath) {
if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) {
return false;
}
const record = cursor as Record<string, unknown>;
if (!Object.hasOwn(record, segment)) {
return false;
}
cursor = record[segment];
}
return cursor !== undefined;
}
function operationClobbersAncestorChild(
operation: ConfigSetOperation,
managedPath: readonly PathSegment[],
merge?: boolean,
): boolean {
if (operation.mutation === "delete") {
return true;
}
const childPath = managedPath.slice(operation.requestedPath.length);
const isMerge = operation.mutation === "merge" || (merge && operation.mutation !== "replace");
return isMerge ? valueHasAutoManagedChild(operation.value, childPath) : true;
}
function findAutoManagedMetaTargets(
operations: readonly ConfigSetOperation[],
merge?: boolean,
): readonly PathSegment[][] {
const matches: PathSegment[][] = [];
const seen = new Set<string>();
const record = (path: readonly PathSegment[]) => {
const key = toDotPath(path);
if (!seen.has(key)) {
seen.add(key);
matches.push([...path]);
}
};
for (const operation of operations) {
const direct = AUTO_MANAGED_CONFIG_META_PATHS.some((path) =>
pathStartsWith(operation.requestedPath, path),
);
if (direct) {
record(operation.requestedPath);
continue;
}
for (const managedPath of AUTO_MANAGED_CONFIG_META_PATHS) {
if (
operation.requestedPath.length < managedPath.length &&
pathStartsWith(managedPath, operation.requestedPath) &&
operationClobbersAncestorChild(operation, managedPath, merge)
) {
record(managedPath);
}
}
}
return matches;
}
function formatAutoManagedMetaError(paths: readonly PathSegment[][]): string {
const targets = paths.map(toDotPath);
const subject = targets.length === 1 ? targets[0] : targets.join(", ");
return [
`${subject} is auto-managed by OpenClaw and cannot be edited; the value would be overwritten on the next config write.`,
"",
"These fields are stamped on every config write to record the OpenClaw version and timestamp that produced the file.",
].join("\n");
}
export function assertConfigPathIsNotAutoManaged(path: PathSegment[]): void {
const targets = findAutoManagedMetaTargets([
{ inputMode: "json", requestedPath: path, setPath: path, value: undefined, mutation: "delete" },
]);
if (targets.length > 0) {
throw new Error(formatAutoManagedMetaError(targets));
}
}
function pruneInactiveGatewayAuthCredentials(params: {
root: Record<string, unknown>;
operations: ConfigSetOperation[];
}): string[] {
const touchedMode = params.operations.some(({ requestedPath }) =>
pathEquals(requestedPath, GATEWAY_AUTH_MODE_PATH),
);
const gateway = params.root.gateway;
if (!touchedMode || !gateway || typeof gateway !== "object" || Array.isArray(gateway)) {
return [];
}
const auth = (gateway as Record<string, unknown>).auth;
if (!auth || typeof auth !== "object" || Array.isArray(auth)) {
return [];
}
const authRecord = auth as Record<string, unknown>;
const mode = typeof authRecord.mode === "string" ? authRecord.mode.trim() : "";
const removedPaths: string[] = [];
const remove = (key: "token" | "password") => {
if (Object.hasOwn(authRecord, key)) {
delete authRecord[key];
removedPaths.push(`gateway.auth.${key}`);
}
};
if (mode === "token") {
remove("password");
} else if (mode === "password") {
remove("token");
} else if (mode === "trusted-proxy") {
remove("token");
remove("password");
}
return removedPaths;
}
function collectChangedLeafPaths(value: unknown, prefix: string): string[] {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return [prefix];
}
const entries = Object.entries(value);
return entries.length === 0
? [prefix]
: entries.flatMap(([key, child]) =>
collectChangedLeafPaths(child, prefix ? `${prefix}.${key}` : key),
);
}
function expandActualChangedPaths(
actualPaths: string[],
requestedPaths: string[],
before: OpenClawConfig,
after: OpenClawConfig,
): string[] {
const expanded = new Set<string>();
for (const actualPath of actualPaths) {
const descendants = requestedPaths.filter(
(requested) => requested !== actualPath && requested.startsWith(`${actualPath}.`),
);
if (descendants.length > 0) {
descendants.forEach((path) => expanded.add(path));
continue;
}
const path = actualPath === "<root>" ? [] : actualPath.split(".");
const beforeValue = getAtPath(before, path);
const afterValue = getAtPath(after, path);
const changedValue = beforeValue.found && !afterValue.found ? beforeValue : afterValue;
const paths =
beforeValue.found !== afterValue.found
? collectChangedLeafPaths(changedValue.value, actualPath)
: [actualPath];
paths.forEach((entry) => expanded.add(entry));
}
return [...expanded];
}
export function configApplyHintForOperations(
operations: ReadonlyArray<{ requestedPath?: PathSegment[] }>,
beforeConfig: OpenClawConfig,
afterConfig: OpenClawConfig,
): string {
const requestedPaths: string[] = [];
for (const operation of operations) {
if (!operation.requestedPath) {
return "Restart the gateway to apply.";
}
requestedPaths.push(toDotPath(operation.requestedPath));
}
const paths = expandActualChangedPaths(
diffConfigPaths(beforeConfig, afterConfig),
requestedPaths,
beforeConfig,
afterConfig,
);
if (
paths.length === 0 ||
paths.some((path) => path === "plugins.entries" || path.startsWith("plugins.entries."))
) {
return "Restart the gateway to apply.";
}
const plan = buildGatewayReloadPlan(paths, { candidateConfig: afterConfig });
if (
plan.restartGateway ||
(plan.hotReasons.length > 0 && resolveGatewayReloadSettings(afterConfig).mode === "off")
) {
return "Restart the gateway to apply.";
}
return plan.hotReasons.length > 0
? "Change will apply without restarting the gateway."
: "No gateway restart needed.";
}
async function loadMutationSchema(): Promise<JsonSchemaRecord | undefined> {
try {
return structuredClone((await readBestEffortRuntimeConfigSchema()).schema) as JsonSchemaRecord;
} catch {
return undefined;
}
}
function formatPolicyFailure(issues: string[]): string {
const lines = [
"Config policy validation failed: unsupported SecretRef usage was detected.",
...issues.slice(0, CONFIG_SET_POLICY_ERROR_MAX_ISSUES).map((issue) => `- ${issue}`),
];
if (issues.length > CONFIG_SET_POLICY_ERROR_MAX_ISSUES) {
lines.push(`- ... ${issues.length - CONFIG_SET_POLICY_ERROR_MAX_ISSUES} more`);
}
return lines.join("\n");
}
export async function runConfigOperations(params: {
runtime: RuntimeEnv;
operations: ConfigSetOperation[];
options: ConfigMutationOptions;
successMode: "set" | "patch";
}) {
const { runtime, operations, options } = params;
if (
operations.some(({ requestedPath }) =>
pathStartsWith(requestedPath, PLUGIN_INSTALL_RECORD_PATH_PREFIX),
)
) {
throw new Error(formatPluginInstallConfigSetError());
}
const autoManagedTargets = findAutoManagedMetaTargets(operations, options.merge);
if (autoManagedTargets.length > 0) {
throw new Error(formatAutoManagedMetaError(autoManagedTargets));
}
const snapshot = await loadValidConfig(runtime);
// Mutate resolved config so runtime defaults never leak into the authored file.
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
const currentConfig = normalizeConfigMutationModelRefs(
structuredClone(snapshot.resolved) as OpenClawConfig,
);
const mutationSchema = await loadMutationSchema();
const unsetPaths: PathSegment[][] = [];
const explicitSetPaths: PathSegment[][] = [];
for (const operation of operations) {
if (operation.mutation === "delete") {
unsetAtPath(next, operation.setPath);
unsetPaths.push(operation.setPath);
continue;
}
explicitSetPaths.push(operation.setPath);
if (operation.mutation === "merge" || (options.merge && operation.mutation !== "replace")) {
mergeAtPath(next, operation.setPath, operation.value, {
numericObjectKeys: params.successMode === "patch",
schema: mutationSchema,
});
} else {
assertNonDestructiveReplacement({
root: next,
path: operation.setPath,
value: operation.value,
allowReplace: options.replace || operation.mutation === "replace",
});
setAtPath(next, operation.setPath, operation.value, {
numericObjectKeys: params.successMode === "patch",
schema: mutationSchema,
});
}
}
const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({ root: next, operations });
const nextConfig = normalizeConfigMutationModelRefs(next as OpenClawConfig);
const normalizedExplicitSetPaths = explicitSetPaths.map(normalizeConfigMutationExplicitSetPath);
const policyIssueLines = formatConfigIssueLines(
collectUnsupportedSecretRefPolicyIssues(nextConfig),
"",
{ normalizeRoot: true },
).map((line) => line.trim());
const pluginIntegrationErrors = collectPluginIntegrationProviderErrors({
config: nextConfig,
operations,
});
if (options.dryRun) {
const hasJsonMode = operations.some(({ inputMode }) => inputMode === "json");
const hasBuilderMode = operations.some(({ inputMode }) => inputMode === "builder");
const hasUnsetMode = operations.some(({ inputMode }) => inputMode === "unset");
const requiresFullSchemaValidation = operations.some(
(operation) =>
operation.inputMode === "unset" ||
(operation.inputMode === "json" && operation.schemaValidated !== true),
);
const checksRefs = hasJsonMode || hasBuilderMode || hasUnsetMode;
const refs = checksRefs ? collectDryRunRefs({ config: nextConfig, operations }) : [];
const selectedRefs = selectDryRunRefsForResolution({
refs,
allowExecInDryRun: Boolean(options.allowExec),
});
const errors: ConfigSetDryRunError[] = [];
const modelRefCheck = await checkTouchedTextModelRefs({
config: nextConfig,
previousConfig: currentConfig,
touchedPaths: operations.map(({ setPath }) => setPath),
redactDependencyValues: true,
});
errors.push(...modelRefCheck.errors.map((message) => ({ kind: "model" as const, message })));
if ((!hasJsonMode || !requiresFullSchemaValidation) && policyIssueLines.length > 0) {
errors.push(...policyIssueLines.map((message) => ({ kind: "schema" as const, message })));
}
errors.push(...pluginIntegrationErrors);
if (requiresFullSchemaValidation) {
errors.push(...collectDryRunSchemaErrors(nextConfig));
}
if (checksRefs) {
errors.push(
...collectDryRunStaticErrorsForSkippedExecRefs({
refs: selectedRefs.skippedExecRefs,
config: nextConfig,
}),
...(await collectDryRunResolvabilityErrors({
refs: selectedRefs.refsToResolve,
config: nextConfig,
})),
);
}
const dedupedErrors = dedupeDryRunErrors(errors);
const dryRunResult: ConfigSetDryRunResult = {
ok: dedupedErrors.length === 0,
operations: operations.length,
configPath: snapshot.path,
inputModes: uniqueValues(operations.map(({ inputMode }) => inputMode)),
checks: {
schema:
requiresFullSchemaValidation ||
policyIssueLines.length > 0 ||
pluginIntegrationErrors.length > 0,
resolvability: checksRefs || modelRefCheck.refsTotal > 0,
resolvabilityComplete:
(checksRefs || modelRefCheck.refsTotal > 0) &&
selectedRefs.skippedExecRefs.length === 0 &&
modelRefCheck.refsChecked === modelRefCheck.refsTotal,
},
refsChecked: selectedRefs.refsToResolve.length + modelRefCheck.refsChecked,
skippedExecRefs: selectedRefs.skippedExecRefs.length,
...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}),
};
if (dedupedErrors.length > 0) {
if (options.json) {
throw new ConfigSetDryRunValidationError(dryRunResult);
}
throw new Error(
formatDryRunFailureMessage({
errors: dedupedErrors,
skippedExecRefs: selectedRefs.skippedExecRefs.length,
}),
);
}
if (options.json) {
writeRuntimeJson(runtime, dryRunResult);
} else {
if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) {
runtime.log(
info(
"Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.",
),
);
}
if (dryRunResult.skippedExecRefs > 0) {
runtime.log(
info(
`Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`,
),
);
}
runtime.log(
info(
`Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`,
),
);
}
return;
}
if (policyIssueLines.length > 0) {
throw new Error(formatPolicyFailure(policyIssueLines));
}
if (pluginIntegrationErrors.length > 0) {
throw new Error(
[
"Config validation failed: plugin-managed SecretRef provider integration is invalid.",
...pluginIntegrationErrors.map((error) => `- ${error.message}`),
].join("\n"),
);
}
const modelRefCheck = await checkTouchedTextModelRefs({
config: nextConfig,
previousConfig: currentConfig,
touchedPaths: operations.map(({ setPath }) => setPath),
redactDependencyValues: true,
});
if (modelRefCheck.errors[0]) {
throw new Error(modelRefCheck.errors[0]);
}
await replaceConfigFile({
nextConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
writeOptions: {
auditOrigin: "cli",
...(unsetPaths.length > 0 ? { unsetPaths } : {}),
...(normalizedExplicitSetPaths.length > 0
? { explicitSetPaths: normalizedExplicitSetPaths }
: {}),
},
});
if (removedGatewayAuthPaths.length > 0) {
runtime.log(
info(
`Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${nextConfig.gateway?.auth?.mode ?? "<unset>"}.`,
),
);
}
const hint = configApplyHintForOperations(operations, currentConfig, nextConfig);
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}`));
} else if (params.successMode === "set") {
runtime.log(info(`Updated ${operations.length} config paths. ${hint}`));
} else {
runtime.log(info(`Applied ${operations.length} config update(s). ${hint}`));
}
}
export function handleConfigMutationError(params: {
err: unknown;
runtime: RuntimeEnv;
options: ConfigMutationOptions;
}) {
if (
params.options.dryRun &&
params.options.json &&
params.err instanceof ConfigSetDryRunValidationError
) {
writeRuntimeJson(params.runtime, params.err.result);
params.runtime.exit(1);
return;
}
params.runtime.error(danger(String(params.err)));
params.runtime.exit(1);
}
+339
View File
@@ -0,0 +1,339 @@
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
import type { ConfigFileSnapshot } from "../config/config.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { attachConfigIssueDiagnostics } from "../config/issue-location.js";
import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
coerceSecretRef,
resolveSecretInputRef,
type PluginIntegrationSecretProviderConfig,
type SecretRef,
} from "../config/types.secrets.js";
import { validateConfigObjectRawWithPlugins } from "../config/validation.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { type RuntimeEnv, defaultRuntime } from "../runtime.js";
import {
isPluginIntegrationSecretProviderConfig,
resolveSecretProviderIntegrationConfig,
} from "../secrets/provider-integrations.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
secretRefKey,
} from "../secrets/ref-contract.js";
import { resolveSecretRefValue } from "../secrets/resolve.js";
import { discoverConfigSecretTargets } from "../secrets/target-registry.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import type { ConfigSetOperation } from "./config-cli-input.js";
import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js";
import type { ConfigSetDryRunError } from "./config-set-dryrun.js";
function formatInvalidConfigRepairHint(
snapshot: Pick<ConfigFileSnapshot, "valid" | "issues" | "warnings" | "legacyIssues">,
doctorMessage: string,
): string {
return isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot)
? formatPluginPackagingRuntimeOutputRecoveryHint()
: `Run \`${formatCliCommand("openclaw doctor --fix")}\` ${doctorMessage}`;
}
export async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
const snapshot = await readConfigFileSnapshot();
if (snapshot.valid) {
return snapshot;
}
runtime.error(`OpenClaw config is invalid: ${shortenHomePath(snapshot.path)}`);
const displayIssues = attachConfigIssueDiagnostics(snapshot.issues, {
raw: snapshot.raw,
parsed: snapshot.parsed,
effective: snapshot.sourceConfig,
configPath: snapshot.path,
formatPathForDisplay: true,
includeReceivedValueHint: true,
});
for (const line of formatConfigIssueLines(displayIssues, "-", { normalizeRoot: true })) {
runtime.error(line);
}
runtime.error(formatInvalidConfigRepairHint(snapshot, "to repair, then retry."));
runtime.exit(1);
return snapshot;
}
export { formatInvalidConfigRepairHint };
function collectSecretRefsFromUnknown(value: unknown): SecretRef[] {
const refs: SecretRef[] = [];
const visit = (candidate: unknown) => {
const ref = coerceSecretRef(candidate);
if (ref) {
refs.push(ref);
return;
}
if (Array.isArray(candidate)) {
candidate.forEach(visit);
} else if (isPlainRecord(candidate)) {
Object.values(candidate).forEach(visit);
}
};
visit(value);
return refs;
}
export function collectDryRunRefs(params: {
config: OpenClawConfig;
operations: ConfigSetOperation[];
}): SecretRef[] {
const refsByKey = new Map<string, SecretRef>();
const targetPaths = new Set<string>();
const providerAliases = new Set<string>();
let includeAllDiscoveredRefs = false;
for (const operation of params.operations) {
if (operation.assignedRef) {
refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef);
}
for (const ref of collectSecretRefsFromUnknown(operation.value)) {
refsByKey.set(secretRefKey(ref), ref);
}
if (operation.touchedSecretTargetPath) {
targetPaths.add(operation.touchedSecretTargetPath);
}
if (operation.touchedProviderAlias) {
providerAliases.add(operation.touchedProviderAlias);
}
includeAllDiscoveredRefs ||= operation.touchesAllSecretRefs === true;
}
if (!includeAllDiscoveredRefs && targetPaths.size === 0 && providerAliases.size === 0) {
return [...refsByKey.values()];
}
const defaults = params.config.secrets?.defaults;
for (const target of discoverConfigSecretTargets(params.config)) {
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults,
});
if (
ref &&
(includeAllDiscoveredRefs ||
targetPaths.has(target.path) ||
providerAliases.has(ref.provider))
) {
refsByKey.set(secretRefKey(ref), ref);
}
}
return [...refsByKey.values()];
}
export async function collectDryRunResolvabilityErrors(params: {
refs: SecretRef[];
config: OpenClawConfig;
}): Promise<ConfigSetDryRunError[]> {
const failures: ConfigSetDryRunError[] = [];
for (const ref of params.refs) {
try {
await resolveSecretRefValue(ref, { config: params.config, env: process.env });
} catch (err) {
failures.push({
kind: "resolvability",
message: String(err),
ref: `${ref.source}:${ref.provider}:${ref.id}`,
});
}
}
return failures;
}
export function collectDryRunStaticErrorsForSkippedExecRefs(params: {
refs: SecretRef[];
config: OpenClawConfig;
}): ConfigSetDryRunError[] {
const failures: ConfigSetDryRunError[] = [];
for (const ref of params.refs) {
const id = ref.id.trim();
const refLabel = `${ref.source}:${ref.provider}:${id}`;
if (!id) {
failures.push({
kind: "resolvability",
message: "Error: Secret reference id is empty.",
ref: refLabel,
});
continue;
}
if (!isValidExecSecretRefId(id)) {
failures.push({
kind: "resolvability",
message: `Error: ${formatExecSecretRefIdValidationMessage()} (ref: ${refLabel}).`,
ref: refLabel,
});
continue;
}
const providerConfig = params.config.secrets?.providers?.[ref.provider];
if (!providerConfig) {
failures.push({
kind: "resolvability",
message: `Error: Secret provider "${ref.provider}" is not configured (ref: ${refLabel}).`,
ref: refLabel,
});
continue;
}
if (providerConfig.source !== ref.source) {
failures.push({
kind: "resolvability",
message: `Error: Secret provider "${ref.provider}" has source "${providerConfig.source}" but ref requests "${ref.source}".`,
ref: refLabel,
});
}
}
return failures;
}
export function selectDryRunRefsForResolution(params: {
refs: SecretRef[];
allowExecInDryRun: boolean;
}): { refsToResolve: SecretRef[]; skippedExecRefs: SecretRef[] } {
const refsToResolve: SecretRef[] = [];
const skippedExecRefs: SecretRef[] = [];
for (const ref of params.refs) {
(ref.source === "exec" && !params.allowExecInDryRun ? skippedExecRefs : refsToResolve).push(
ref,
);
}
return { refsToResolve, skippedExecRefs };
}
export function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] {
const validated = validateConfigObjectRawWithPlugins(config);
if (validated.ok) {
return [];
}
return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({
kind: "schema",
message,
}));
}
function touchesSecretProviderCollection(path: readonly string[]): boolean {
return (
(path.length === 1 && path[0] === "secrets") ||
(path.length === 2 && path[0] === "secrets" && path[1] === "providers")
);
}
export function collectPluginIntegrationProviderErrors(params: {
config: OpenClawConfig;
operations: ConfigSetOperation[];
}): ConfigSetDryRunError[] {
const providers = params.config.secrets?.providers ?? {};
let validateAllProviders = false;
const touchedProviderAliases = new Set<string>();
for (const operation of params.operations) {
if (operation.touchedProviderAlias) {
touchedProviderAliases.add(operation.touchedProviderAlias);
}
if (operation.assignedRef) {
touchedProviderAliases.add(operation.assignedRef.provider);
}
for (const ref of collectSecretRefsFromUnknown(operation.value)) {
touchedProviderAliases.add(ref.provider);
}
validateAllProviders ||= touchesSecretProviderCollection(operation.setPath);
}
if (!validateAllProviders && touchedProviderAliases.size === 0) {
return [];
}
const integrationProviders: Array<{
alias: string;
provider: PluginIntegrationSecretProviderConfig;
}> = [];
for (const [alias, provider] of Object.entries(providers)) {
if (
(validateAllProviders || touchedProviderAliases.has(alias)) &&
isPluginIntegrationSecretProviderConfig(provider)
) {
integrationProviders.push({ alias, provider });
}
}
if (integrationProviders.length === 0) {
return [];
}
const manifestRegistry = loadPluginMetadataSnapshot({
config: params.config,
env: process.env,
}).manifestRegistry;
const errors: ConfigSetDryRunError[] = [];
for (const { alias, provider } of integrationProviders) {
const resolved = resolveSecretProviderIntegrationConfig({
manifestRegistry,
providerAlias: alias,
providerConfig: provider,
config: params.config,
env: process.env,
});
if (!resolved.ok) {
errors.push({ kind: "schema", message: `secrets.providers.${alias}: ${resolved.reason}` });
}
}
return errors;
}
export function dedupeDryRunErrors(errors: ConfigSetDryRunError[]): ConfigSetDryRunError[] {
const deduped: ConfigSetDryRunError[] = [];
const seen = new Set<string>();
for (const error of errors) {
const key =
error.kind === "resolvability"
? `${error.kind}\u0000${error.ref ?? ""}\u0000${error.message}`
: `${error.kind}\u0000${error.message}`;
if (!seen.has(key)) {
seen.add(key);
deduped.push(error);
}
}
return deduped;
}
export function formatDryRunFailureMessage(params: {
errors: ConfigSetDryRunError[];
skippedExecRefs: number;
}): string {
const missingPathErrors = params.errors.filter((error) => error.kind === "missing-path");
const schemaErrors = params.errors.filter((error) => error.kind === "schema");
const resolveErrors = params.errors.filter((error) => error.kind === "resolvability");
const modelErrors = params.errors.filter((error) => error.kind === "model");
const lines: string[] = missingPathErrors.map((error) => error.message);
if (schemaErrors.length > 0) {
lines.push(
"Dry run failed: config schema validation failed.",
...schemaErrors.map((error) => `- ${error.message}`),
);
}
if (resolveErrors.length > 0) {
lines.push(
`Dry run failed: ${resolveErrors.length} SecretRef assignment(s) could not be resolved.`,
...resolveErrors
.slice(0, 5)
.map((error) => `- ${error.ref ?? "<unknown-ref>"} -> ${error.message}`),
);
if (resolveErrors.length > 5) {
lines.push(`- ... ${resolveErrors.length - 5} more`);
}
}
if (modelErrors.length > 0) {
lines.push(
"Dry run failed: model reference validation failed.",
...modelErrors.map((error) => `- ${error.message}`),
);
}
if (params.skippedExecRefs > 0) {
lines.push(
`Dry run note: skipped ${params.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`,
);
}
return lines.join("\n");
}
+85 -2472
View File
File diff suppressed because it is too large Load Diff