mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
6dbe278283
commit
eac10a01d5
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(".");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user