diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index c03ca7bf6fd3..fe4364ad665a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/cli/config-cli-input.ts b/src/cli/config-cli-input.ts new file mode 100644 index 000000000000..74bdd9fc1a40 --- /dev/null +++ b/src/cli/config-cli-input.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 | undefined { + if (!entries || entries.length === 0) { + return undefined; + } + const env: Record = {}; + 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." (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(".") ?? ""}: ${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; + 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(".") ?? ""}: ${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 ."); + } + if (params.value !== undefined) { + throw modeError("ref builder mode does not accept ."); + } + if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) { + throw modeError( + "ref builder mode requires --ref-provider , --ref-source , and --ref-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 ."); + } + if (params.value !== undefined) { + throw modeError("provider builder mode does not accept ."); + } + 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 when batch mode is not used."); + } + if (params.value === undefined) { + throw modeError("value/json mode requires ."); + } + 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 { + if (process.stdin.isTTY) { + throw configPatchModeError( + "--stdin refuses to read from an interactive terminal; pipe input or use --file .", + ); + } + 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 for larger patches.`, + ), + }); + return bytes.toString("utf8"); +} + +async function readConfigPatchInput(opts: ConfigPatchOptions): Promise { + const file = normalizeOptionalString(opts.file); + const stdin = Boolean(opts.stdin); + if (Boolean(file) === stdin) { + throw configPatchModeError("provide exactly one of --file 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(); + 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 { + 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 ")}`, + ` ${formatCliCommand("openclaw plugins update ")}`, + ` ${formatCliCommand("openclaw plugins uninstall ")}`, + ].join("\n"); +} diff --git a/src/cli/config-cli-model-normalization.ts b/src/cli/config-cli-model-normalization.ts new file mode 100644 index 000000000000..9c582563f04f --- /dev/null +++ b/src/cli/config-cli-model-normalization.ts @@ -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 = { ...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["providers"] | undefined, +): unknown { + if (!isPlainRecord(providers)) { + return providers; + } + + let mutated = false; + const nextProviders: Record = { ...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; +} diff --git a/src/cli/config-cli-path.ts b/src/cli/config-cli-path.ts new file mode 100644 index 000000000000..a7fde934989f --- /dev/null +++ b/src/cli/config-cli-path.ts @@ -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, 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; + 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 ")} to override the inherited value.`; + } + return `Config path not found: ${params.path}. Nothing was changed. Run ${formatCliCommand("openclaw config get ")} 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 { + 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[] { + 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) + : 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, + 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; + 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)[last] = value; +} + +function modelArrayIds(value: unknown): Set | null { + if (!Array.isArray(value)) { + return null; + } + const ids = new Set(); + 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(); + 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 = { ...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, + 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; + 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, 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; + 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; + 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("."); +} diff --git a/src/cli/config-cli-runner.ts b/src/cli/config-cli-runner.ts new file mode 100644 index 000000000000..8e60190efb42 --- /dev/null +++ b/src/cli/config-cli-runner.ts @@ -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; + 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(); + 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; + 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).auth; + if (!auth || typeof auth !== "object" || Array.isArray(auth)) { + return []; + } + const authRecord = auth as Record; + 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(); + 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 === "" ? [] : 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 { + 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; + 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 ?? ""}.`, + ), + ); + } + 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); +} diff --git a/src/cli/config-cli-validation.ts b/src/cli/config-cli-validation.ts new file mode 100644 index 000000000000..3ed8e9e2f352 --- /dev/null +++ b/src/cli/config-cli-validation.ts @@ -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, + 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(); + const targetPaths = new Set(); + const providerAliases = new Set(); + 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 { + 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(); + 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(); + 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 ?? ""} -> ${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"); +} diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index ee5247132ded..13da74b9b849 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,2409 +1,75 @@ // Config CLI command implementation for get/set/unset/patch/validate and secret refs. -import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit"; -import { expectDefined } from "@openclaw/normalization-core"; -import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce"; -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { - normalizeStringEntries, - uniqueValues, -} from "@openclaw/normalization-core/string-normalization"; import type { Command } from "commander"; -import JSON5 from "json5"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; -import { normalizeConfiguredProviderCatalogModelId } from "../agents/model-ref-shared.js"; -import { - type ConfigFileSnapshot, - readConfigFileSnapshot, - replaceConfigFile, -} from "../config/config.js"; -import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js"; +import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { attachConfigIssueDiagnostics } from "../config/issue-location.js"; -import { - normalizeAgentModelMapForConfig, - normalizeAgentModelRefForConfig, -} from "../config/model-input.js"; import { CONFIG_PATH, resolveConfigPath } from "../config/paths.js"; -import { isPluginPackagingRuntimeOutputInvalidConfigSnapshot } from "../config/recovery-policy.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { readBestEffortRuntimeConfigSchema } from "../config/runtime-schema.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - coerceSecretRef, - isValidEnvSecretRefId, - resolveSecretInputRef, - type PluginIntegrationSecretProviderConfig, - type SecretProviderConfig, - type SecretRef, - type SecretRefSource, -} from "../config/types.secrets.js"; -import { - collectUnsupportedSecretRefPolicyIssues, - validateConfigObjectRawWithPlugins, -} from "../config/validation.js"; -import { SecretProviderSchema } from "../config/zod-schema.core.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, success, warn } from "../globals.js"; -import { hasErrnoCode } from "../infra/errors.js"; -import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; -import { isBlockedObjectKey } from "../infra/prototype-keys.js"; -import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; -import { ExitError, type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; -import { defaultRuntime } from "../runtime.js"; -import { - isPluginIntegrationSecretProviderConfig, - resolveSecretProviderIntegrationConfig, -} from "../secrets/provider-integrations.js"; -import { - formatExecSecretRefIdValidationMessage, - isValidExecSecretRefId, - isValidFileSecretRefId, - isValidSecretProviderAlias, - secretRefKey, - validateExecSecretRefId, -} from "../secrets/ref-contract.js"; -import { resolveSecretRefValue } from "../secrets/resolve.js"; -import { - discoverConfigSecretTargets, - resolveConfigSecretTargetByPath, -} from "../secrets/target-registry.js"; -import { parseConfigPathArrayIndex } from "../shared/path-array-index.js"; +import { ExitError, type RuntimeEnv, defaultRuntime, writeRuntimeJson } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { + buildConfigSetOperations, + buildUnsetOperation, + ConfigSetDryRunValidationError, + configPatchModeError, + modeError, + readConfigPatchOperations, + type ConfigPatchOptions, + type ConfigUnsetOptions, +} from "./config-cli-input.js"; +import { normalizeConfigMutationModelRefs } from "./config-cli-model-normalization.js"; +import { + formatConfigUnsetMissingPathMessage, + getAtPath, + parseConfigSetPath, + unsetAtPath, +} from "./config-cli-path.js"; +import { + assertConfigPathIsNotAutoManaged, + configApplyHintForOperations, + handleConfigMutationError, + runConfigOperations, +} from "./config-cli-runner.js"; +import { formatInvalidConfigRepairHint, loadValidConfig } from "./config-cli-validation.js"; import { checkTouchedTextModelRefs } from "./config-model-validation.js"; -import { formatPluginPackagingRuntimeOutputRecoveryHint } from "./config-recovery-hints.js"; -import type { - ConfigSetDryRunError, - ConfigSetDryRunInputMode, - ConfigSetDryRunResult, -} from "./config-set-dryrun.js"; import { hasBatchMode, hasProviderBuilderOptions, hasRefBuilderOptions, parseBatchSource, - readConfigMutationFileSync, - type ConfigSetBatchEntry, type ConfigSetOptions, } from "./config-set-input.js"; import { resolveConfigSetMode } from "./config-set-parser.js"; -import { formatStrictJsonParseFailure } from "./error-format.js"; import { setCommandJsonMode } from "./program/json-mode.js"; -type PathSegment = string; -type ConfigSetParseOpts = { - strictJson?: boolean; -}; -type ConfigSetInputMode = ConfigSetDryRunInputMode; -type ConfigSetOperation = { - inputMode: ConfigSetInputMode; - requestedPath: PathSegment[]; - setPath: PathSegment[]; - value: unknown; - mutation?: "set" | "merge" | "replace" | "delete"; - schemaValidated?: boolean; - touchesAllSecretRefs?: boolean; - touchedSecretTargetPath?: string; - touchedProviderAlias?: string; - assignedRef?: SecretRef; -}; -type ConfigPatchOptions = { - file?: string | undefined; - stdin?: boolean | undefined; - dryRun?: boolean | undefined; - allowExec?: boolean | undefined; - json?: boolean | undefined; - replacePath?: string[] | undefined; -}; -type ConfigUnsetOptions = { - dryRun?: boolean | undefined; - allowExec?: boolean | undefined; - json?: boolean | undefined; -}; -type ConfigMutationOptions = { - dryRun?: boolean | undefined; - allowExec?: boolean | undefined; - json?: boolean | undefined; - merge?: boolean | undefined; - replace?: boolean | undefined; -}; +export { parseConfigSetPath } from "./config-cli-path.js"; -function normalizeAgentDefaultModelValueForConfigMutation(value: unknown): unknown { - if (typeof value === "string") { - return normalizeAgentModelRefForConfig(value); - } - if (!isPlainRecord(value)) { - return value; - } - - const next: Record = { ...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 normalizeAgentListModelRefsForConfigMutation(value: unknown): unknown { - // Config mutation normalizes model refs at write time so later readers see canonical ids. - 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 = normalizeAgentDefaultModelValueForConfigMutation(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 normalizeProviderCatalogModelsForConfigMutation( - 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 normalizeModelProviderRefsForConfigMutation( - providers: NonNullable["providers"] | undefined, -): unknown { - if (!isPlainRecord(providers)) { - return providers; - } - - let mutated = false; - const nextProviders: Record = { ...providers }; - for (const [provider, providerConfig] of Object.entries(providers)) { - if (!isPlainRecord(providerConfig)) { - continue; - } - const models = normalizeProviderCatalogModelsForConfigMutation(provider, providerConfig.models); - if (models === providerConfig.models) { - continue; - } - nextProviders[provider] = { ...providerConfig, models }; - mutated = true; - } - - return mutated ? nextProviders : providers; -} - -function normalizeConfigMutationModelRefs(cfg: OpenClawConfig): OpenClawConfig { - const defaults = cfg.agents?.defaults; - const agentList = cfg.agents?.list; - const providers = cfg.models?.providers; - const normalizedAgentList = normalizeAgentListModelRefsForConfigMutation(agentList); - const normalizedProviders = normalizeModelProviderRefsForConfigMutation(providers) as - | typeof providers - | undefined; - - return { - ...cfg, - ...(defaults || normalizedAgentList !== agentList - ? { - agents: { - ...cfg.agents, - ...(defaults - ? { - defaults: { - ...defaults, - ...(defaults.model !== undefined - ? { - model: normalizeAgentDefaultModelValueForConfigMutation( - 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), - }; -} - -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; -} - -const GATEWAY_AUTH_MODE_PATH: PathSegment[] = ["gateway", "auth", "mode"]; -const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"]; -const PLUGIN_INSTALL_RECORD_PATH_PREFIX: PathSegment[] = ["plugins", "installs"]; -const CONFIG_SET_EXAMPLE_VALUE = formatCliCommand( - "openclaw config set gateway.port 19001 --strict-json", -); -const CONFIG_SET_EXAMPLE_REF = formatCliCommand( - "openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN", -); -const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand( - "openclaw config set secrets.providers.vault --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json", -); -const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand( - "openclaw config set --batch-file ./config-set.batch.json --dry-run", -); -const CONFIG_PATCH_EXAMPLE_FILE = formatCliCommand( - "openclaw config patch --file ./openclaw.patch.json5 --dry-run", -); -const CONFIG_PATCH_EXAMPLE_STDIN = formatCliCommand("openclaw config patch --stdin"); const CONFIG_SET_DESCRIPTION = [ "Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).", "Examples:", - CONFIG_SET_EXAMPLE_VALUE, - CONFIG_SET_EXAMPLE_REF, - CONFIG_SET_EXAMPLE_PROVIDER, - CONFIG_SET_EXAMPLE_BATCH, + formatCliCommand("openclaw config set gateway.port 19001 --strict-json"), + formatCliCommand( + "openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN", + ), + formatCliCommand( + "openclaw config set secrets.providers.vault --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json", + ), + formatCliCommand("openclaw config set --batch-file ./config-set.batch.json --dry-run"), ].join("\n"); + const CONFIG_PATCH_DESCRIPTION = [ "Patch config from a JSON5 object in one validated write.", "Objects merge recursively, arrays/scalars replace, and null deletes a path.", "Examples:", - CONFIG_PATCH_EXAMPLE_FILE, - CONFIG_PATCH_EXAMPLE_STDIN, + formatCliCommand("openclaw config patch --file ./openclaw.patch.json5 --dry-run"), + formatCliCommand("openclaw config patch --stdin"), ].join("\n"); -const CONFIG_SET_POLICY_ERROR_MAX_ISSUES = 5; -const CONFIG_PATCH_STDIN_MAX_BYTES = 1024 * 1024; - -class ConfigSetDryRunValidationError extends Error { - constructor(readonly result: ConfigSetDryRunResult) { - super("config set dry-run validation failed"); - this.name = "ConfigSetDryRunValidationError"; - } -} - -function isIndexSegment(raw: string): boolean { - return parseIndexSegment(raw) !== undefined; -} - -function parseIndexSegment(raw: string): number | undefined { - return parseConfigPathArrayIndex(raw); -} - -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; -} - -// A buffered key with characters that are all whitespace is stray text between -// path boundaries (for example, "gateway. .port"). Reject it like an empty segment. -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 = ""; - // Tracks whether a bracket segment was emitted since the last "." boundary, so - // "foo[0].bar" is accepted while empty key segments are rejected. - 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 === "[") { - // A bracket may start the path ("[0]"), follow a key ("foo[0]"), or follow - // another bracket ("foo[0][1]"), but a bracket right after a "." boundary with - // no key (e.g. "gateway.[port]") is an empty segment, same as a double dot. - 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); -} - -function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { - const trimmed = raw.trim(); - if (opts.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; - } -} - -function hasOwnPathKey(value: Record, key: string): boolean { - return Object.hasOwn(value, key); -} - -function formatDoctorHint(message: string): string { - return `Run \`${formatCliCommand("openclaw doctor --fix")}\` ${message}`; -} - -function formatInvalidConfigRepairHint( - snapshot: Pick, - doctorMessage: string, -): string { - return isPluginPackagingRuntimeOutputInvalidConfigSnapshot(snapshot) - ? formatPluginPackagingRuntimeOutputRecoveryHint() - : formatDoctorHint(doctorMessage); -} - -function formatUnsupportedSecretRefPolicyFailureMessage(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"); -} - -function validatePathSegments(path: PathSegment[]): void { - for (const segment of path) { - if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) { - throw new Error(`Invalid path segment: ${segment}`); - } - } -} - -function getAtPath(root: unknown, path: 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)) { - if (!isIndexSegment(segment)) { - return { found: false }; - } - const index = parseIndexSegment(segment); - if (index === undefined || index >= current.length) { - return { found: false }; - } - current = current[index]; - continue; - } - const record = current as Record; - if (!hasOwnPathKey(record, segment)) { - return { found: false }; - } - current = record[segment]; - } - return { found: true, value: current }; -} - -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 ")} to override the inherited value.`; - } - return `Config path not found: ${params.path}. Nothing was changed. Run ${formatCliCommand("openclaw config get ")} first if you are unsure of the path.`; -} - -type JsonSchemaRecord = { - type?: unknown; - properties?: unknown; - additionalProperties?: unknown; - items?: unknown; - anyOf?: unknown; - oneOf?: unknown; - allOf?: unknown; -}; - -type SetAtPathOptions = { - numericObjectKeys?: boolean; - schema?: JsonSchemaRecord; -}; - -function isSchemaRecord(value: unknown): value is JsonSchemaRecord { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function schemaTypes(schema: JsonSchemaRecord): Set { - 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[] { - 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) - : undefined; - const explicit = properties?.[segment]; - if (isSchemaRecord(explicit)) { - schemas.push(explicit); - continue; - } - if (isSchemaRecord(alternative.additionalProperties)) { - schemas.push(alternative.additionalProperties); - } - } - return schemas; -} - -function schemasAtPath(schema: JsonSchemaRecord | undefined, path: readonly PathSegment[]) { - 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); - const schemaPreference = schemaPrefersArrayAtPath(params.options?.schema, parentPath); - if (schemaPreference !== undefined) { - return schemaPreference; - } - return true; -} - -function setAtPath( - root: Record, - 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 next = path[i + 1]; - const nextIsIndex = shouldCreateArrayForMissingPathSegment({ - path, - segmentIndex: i, - next, - options, - }); - if (Array.isArray(current)) { - if (!isIndexSegment(segment)) { - throw new Error(`Expected numeric index for array segment "${segment}"`); - } - 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; - const existing = hasOwnPathKey(record, segment) ? record[segment] : undefined; - if (!existing || typeof existing !== "object") { - record[segment] = nextIsIndex ? [] : {}; - } - current = record[segment]; - } - - if (Array.isArray(current)) { - if (!isIndexSegment(last)) { - throw new Error(`Expected numeric index for array segment "${last}"`); - } - 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)[last] = value; -} - -function modelArrayIds(value: unknown): Set | null { - if (!Array.isArray(value)) { - return null; - } - const ids = new Set(); - 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(); - 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 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 = { ...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.`); -} - -function mergeAtPath( - root: Record, - path: PathSegment[], - value: unknown, - options?: SetAtPathOptions, -): void { - const existing = getAtPath(root, path); - if (!existing.found) { - setAtPath(root, path, value, options); - return; - } - setAtPath(root, path, mergeConfigValue(existing.value, value, path), options); -} - -function isProviderModelListPath(path: PathSegment[]): boolean { - return ( - path.length === 4 && path[0] === "models" && path[1] === "providers" && path[3] === "models" - ); -} - -function isProtectedMapReplacementPath(path: PathSegment[]): boolean { - if (path.join(".") === "agents.defaults.models") { - return true; - } - if (path.join(".") === "models.providers") { - return true; - } - if (path.length === 3 && path[0] === "models" && path[1] === "providers") { - return true; - } - if (path.join(".") === "plugins.entries") { - return true; - } - if (path.join(".") === "auth.profiles") { - return true; - } - return false; -} - -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}`; -} - -function assertNonDestructiveReplacement(params: { - root: Record; - 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 }; - -function unsetAtPath(root: Record, 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)) { - if (!isIndexSegment(segment)) { - return { removed: false }; - } - const index = parseIndexSegment(segment); - if (index === undefined || index >= current.length) { - return { removed: false }; - } - current = current[index]; - continue; - } - const record = current as Record; - if (!hasOwnPathKey(record, segment)) { - return { removed: false }; - } - current = record[segment]; - } - - if (Array.isArray(current)) { - if (!isIndexSegment(last)) { - return { removed: false }; - } - 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; - if (!hasOwnPathKey(record, last)) { - return { removed: false }; - } - delete record[last]; - return { removed: true, leafContainer: "object" }; -} - -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; -} - -/** Parse and validate the exact path grammar accepted by config set/get/unset. */ -export function parseConfigSetPath(path: string): string[] { - const parsedPath = parsePath(path); - if (parsedPath.length === 0) { - throw new Error("Path is empty."); - } - validatePathSegments(parsedPath); - return parsedPath; -} - -function pathEquals(path: PathSegment[], expected: PathSegment[]): boolean { - return ( - path.length === expected.length && path.every((segment, index) => segment === expected[index]) - ); -} - -function pruneInactiveGatewayAuthCredentials(params: { - root: Record; - operations: ConfigSetOperation[]; -}): string[] { - const touchedGatewayAuthMode = params.operations.some((operation) => - pathEquals(operation.requestedPath, GATEWAY_AUTH_MODE_PATH), - ); - if (!touchedGatewayAuthMode) { - return []; - } - - const gatewayRaw = params.root.gateway; - if (!gatewayRaw || typeof gatewayRaw !== "object" || Array.isArray(gatewayRaw)) { - return []; - } - const gateway = gatewayRaw as Record; - const authRaw = gateway.auth; - if (!authRaw || typeof authRaw !== "object" || Array.isArray(authRaw)) { - return []; - } - const auth = authRaw as Record; - const mode = normalizeOptionalString(auth.mode) ?? ""; - - const removedPaths: string[] = []; - const remove = (key: "token" | "password") => { - if (Object.hasOwn(auth, key)) { - delete auth[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 toDotPath(path: PathSegment[]): string { - return path.join("."); -} - -const RESTART_HINT = "Restart the gateway to apply."; -const HOT_RELOAD_HINT = "Change will apply without restarting the gateway."; -const NO_RELOAD_HINT = "No gateway restart needed."; - -function isPluginEntryConfigPath(path: string): boolean { - // CLI hints are operator guidance. Keep plugin entry writes conservative - // because the CLI cannot prove every plugin's reload metadata is loaded. - return path === "plugins.entries" || path.startsWith("plugins.entries."); -} - -function configApplyHintForPaths(paths: string[], afterConfig: OpenClawConfig): string { - if (paths.length === 0) { - return RESTART_HINT; - } - if (paths.some(isPluginEntryConfigPath)) { - return RESTART_HINT; - } - const plan = buildGatewayReloadPlan(paths, { candidateConfig: afterConfig }); - if (plan.restartGateway) { - return RESTART_HINT; - } - if (plan.hotReasons.length > 0) { - const { mode } = resolveGatewayReloadSettings(afterConfig); - if (mode === "off") { - return RESTART_HINT; - } - return HOT_RELOAD_HINT; - } - return NO_RELOAD_HINT; -} - -function configApplyHintForOperations( - operations: ReadonlyArray<{ requestedPath?: PathSegment[] }>, - beforeConfig: OpenClawConfig, - afterConfig: OpenClawConfig, -): string { - const requestedPaths: string[] = []; - for (const operation of operations) { - if (!operation.requestedPath) { - return RESTART_HINT; - } - requestedPaths.push(toDotPath(operation.requestedPath)); - } - return configApplyHintForPaths( - expandActualChangedPathsWithRequestedDescendants( - diffConfigPaths(beforeConfig, afterConfig), - requestedPaths, - beforeConfig, - afterConfig, - ), - afterConfig, - ); -} - -function expandActualChangedPathsWithRequestedDescendants( - actualChangedPaths: string[], - requestedPaths: string[], - beforeConfig: OpenClawConfig, - afterConfig: OpenClawConfig, -): string[] { - const expanded = new Set(); - for (const actualPath of actualChangedPaths) { - const requestedDescendants = requestedPaths.filter( - (requestedPath) => requestedPath !== actualPath && requestedPath.startsWith(`${actualPath}.`), - ); - if (requestedDescendants.length > 0) { - for (const requestedPath of requestedDescendants) { - expanded.add(requestedPath); - } - continue; - } - for (const expandedPath of expandWholeValueChangePath(actualPath, beforeConfig, afterConfig)) { - expanded.add(expandedPath); - } - } - return [...expanded]; -} - -function expandWholeValueChangePath( - actualPath: string, - beforeConfig: OpenClawConfig, - afterConfig: OpenClawConfig, -): string[] { - const path = actualPath === "" ? [] : actualPath.split("."); - const before = getAtPath(beforeConfig, path); - const after = getAtPath(afterConfig, path); - if (before.found && !after.found) { - return collectChangedLeafPaths(before.value, actualPath); - } - if (!before.found && after.found) { - return collectChangedLeafPaths(after.value, actualPath); - } - return [actualPath]; -} - -function collectChangedLeafPaths(value: unknown, prefix: string): string[] { - if (!isPlainRecord(value)) { - return [prefix]; - } - const entries = Object.entries(value); - if (entries.length === 0) { - return [prefix]; - } - return entries.flatMap(([key, child]) => - collectChangedLeafPaths(child, prefix ? `${prefix}.${key}` : key), - ); -} - -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") { - const validated = validateExecSecretRefId(id); - if (!validated.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 | undefined { - if (!entries || entries.length === 0) { - return undefined; - } - const env: Record = {}; - 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 { - const expectedPrefixMatches = - path.length === 3 && - path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && - path[1] === SECRET_PROVIDER_PATH_PREFIX[1]; - if (!expectedPrefixMatches) { - throw new Error( - 'Provider builder mode requires path "secrets.providers." (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 && opts.providerArg.length > 0 - ? { 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 && opts.providerPassEnv.length > 0 - ? { passEnv: normalizeStringEntries(opts.providerPassEnv) } - : {}), - ...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0 - ? { 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]; - const issuePath = issue?.path?.join(".") ?? ""; - const issueMessage = issue?.message ?? "Invalid provider config."; - throw new Error(`Provider builder config invalid at ${issuePath}: ${issueMessage}`); - } - 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; - 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 buildRefAssignmentOperation(params: { - requestedPath: PathSegment[]; - ref: SecretRef; - inputMode: ConfigSetInputMode; -}): 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, - // Only registry-known SecretRef targets have had their schema shape validated here. - ...(resolved ? { schemaValidated: true } : {}), - touchedSecretTargetPath: resolved - ? toDotPath(resolved.pathSegments) - : toDotPath(params.requestedPath), - assignedRef: params.ref, - ...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}), - }; -} - -function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null { - if ( - path.length >= 3 && - path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && - path[1] === SECRET_PROVIDER_PATH_PREFIX[1] - ) { - return path[2] ?? null; - } - return 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 buildValueAssignmentOperation(params: { - requestedPath: PathSegment[]; - value: unknown; - inputMode: ConfigSetInputMode; -}): 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[] { - const operations: ConfigSetOperation[] = []; - for (const [index, entry] of entries.entries()) { - const path = parseConfigSetPath(entry.path); - if (entry.ref !== undefined) { - const ref = parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`); - operations.push( - buildRefAssignmentOperation({ - requestedPath: path, - ref, - inputMode: "json", - }), - ); - continue; - } - if (entry.provider !== undefined) { - const alias = parseProviderAliasPath(path); - const validated = SecretProviderSchema.safeParse(entry.provider); - if (!validated.success) { - const issue = validated.error.issues[0]; - const issuePath = issue?.path?.join(".") ?? ""; - throw new Error( - `batch[${index}].provider invalid at ${issuePath}: ${issue?.message ?? ""}`, - ); - } - operations.push({ - inputMode: "json", - requestedPath: path, - setPath: path, - value: validated.data, - schemaValidated: true, - touchedProviderAlias: alias, - }); - continue; - } - operations.push( - buildValueAssignmentOperation({ - requestedPath: path, - value: entry.value, - inputMode: "json", - }), - ); - } - return operations; -} - -function configPatchModeError(message: string): Error { - return new Error(`config patch mode error: ${message}`); -} - -async function readStdinText(): Promise { - if (process.stdin.isTTY) { - throw configPatchModeError( - "--stdin refuses to read from an interactive terminal; pipe input or use --file .", - ); - } - 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 for larger patches.`, - ), - }); - return bytes.toString("utf8"); -} - -async function readConfigPatchInput(opts: ConfigPatchOptions): Promise { - const file = normalizeOptionalString(opts.file); - const stdin = Boolean(opts.stdin); - if (Boolean(file) === stdin) { - throw configPatchModeError("provide exactly one of --file 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 parseReplacePaths(paths: string[] | undefined): PathSegment[][] { - return (paths ?? []).map((path) => parseConfigSetPath(path)); -} - -function pathKey(path: PathSegment[]): string { - return JSON.stringify(path); -} - -function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation { - return { - inputMode: "json", - requestedPath: path, - setPath: path, - value: undefined, - mutation: "delete", - }; -} - -function buildUnsetOperation(path: PathSegment[]): ConfigSetOperation { - const resolved = resolveConfigSecretTargetByPath(path); - const providerAlias = parseProviderAliasFromTargetPath(path); - const touchesAllSecretRefs = touchesSecretProviderCollection(path) || touchesSecretDefaults(path); - return { - inputMode: "unset", - requestedPath: path, - setPath: path, - value: undefined, - mutation: "delete", - ...(touchesAllSecretRefs ? { 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; - if (ref) { - return { - ...buildRefAssignmentOperation({ - requestedPath: params.path, - ref: parseSecretRefFromUnknown(params.value, `patch.${toDotPath(params.path)}`), - inputMode: "json", - }), - ...(params.mutation ? { mutation: params.mutation } : {}), - }; - } - return { - ...buildValueAssignmentOperation({ - requestedPath: params.path, - value: params.value, - inputMode: "json", - }), - ...(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 replacePathKeys = new Set(params.replacePaths.map(pathKey)); - const matchedReplacePathKeys = new Set(); - 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; -} - -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)) { - for (const entry of candidate) { - visit(entry); - } - return; - } - if (isPlainRecord(candidate)) { - for (const entry of Object.values(candidate)) { - visit(entry); - } - } - }; - visit(value); - return refs; -} - -function modeError(message: string): Error { - return new Error(`config set mode error: ${message}`); -} - -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 ."); - } - if (params.value !== undefined) { - throw modeError("ref builder mode does not accept ."); - } - if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) { - throw modeError( - "ref builder mode requires --ref-provider , --ref-source , and --ref-id .", - ); - } - const ref = parseSecretRefBuilder({ - provider: params.opts.refProvider, - source: params.opts.refSource, - id: params.opts.refId, - fieldPrefix: "ref", - }); - return [ - buildRefAssignmentOperation({ - requestedPath: parsedPath, - ref, - inputMode: "builder", - }), - ]; - } - - if (modeResolution.mode === "provider_builder") { - if (!pathProvided || !parsedPath) { - throw modeError("provider builder mode requires ."); - } - if (params.value !== undefined) { - throw modeError("provider builder mode does not accept ."); - } - const alias = parseProviderAliasPath(parsedPath); - const provider = buildProviderFromBuilder(params.opts); - return [ - { - inputMode: "builder", - requestedPath: parsedPath, - setPath: parsedPath, - value: provider, - schemaValidated: true, - touchedProviderAlias: alias, - }, - ]; - } - - if (!pathProvided || !parsedPath) { - throw modeError("value/json mode requires when batch mode is not used."); - } - if (params.value === undefined) { - throw modeError("value/json mode requires ."); - } - const parsedValue = parseValue(params.value, { strictJson }); - return [ - buildValueAssignmentOperation({ - requestedPath: parsedPath, - value: parsedValue, - inputMode: modeResolution.mode === "json" ? "json" : "value", - }), - ]; -} - -function collectDryRunRefs(params: { - config: OpenClawConfig; - operations: ConfigSetOperation[]; -}): SecretRef[] { - const refsByKey = new Map(); - const targetPaths = new Set(); - const providerAliases = new Set(); - 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) { - continue; - } - if ( - includeAllDiscoveredRefs || - targetPaths.has(target.path) || - providerAliases.has(ref.provider) - ) { - refsByKey.set(secretRefKey(ref), ref); - } - } - return [...refsByKey.values()]; -} - -async function collectDryRunResolvabilityErrors(params: { - refs: SecretRef[]; - config: OpenClawConfig; -}): Promise { - 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; -} - -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; -} - -function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInDryRun: boolean }): { - refsToResolve: SecretRef[]; - skippedExecRefs: SecretRef[]; -} { - const refsToResolve: SecretRef[] = []; - const skippedExecRefs: SecretRef[] = []; - for (const ref of params.refs) { - if (ref.source === "exec" && !params.allowExecInDryRun) { - skippedExecRefs.push(ref); - continue; - } - refsToResolve.push(ref); - } - return { refsToResolve, skippedExecRefs }; -} - -function pathStartsWith(path: readonly PathSegment[], prefix: readonly PathSegment[]): boolean { - return prefix.every((segment, index) => path[index] === segment); -} - -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 ")}`, - ` ${formatCliCommand("openclaw plugins update ")}`, - ` ${formatCliCommand("openclaw plugins uninstall ")}`, - ].join("\n"); -} - -function isAutoManagedMetaPath(path: ReadonlyArray): boolean { - return AUTO_MANAGED_CONFIG_META_PATHS.some((managedPath) => pathStartsWith(path, managedPath)); -} - -function valueHasAutoManagedChild(value: unknown, childPath: ReadonlyArray): boolean { - let cursor: unknown = value; - for (const segment of childPath) { - if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) { - return false; - } - if (typeof segment !== "string") { - return false; - } - const record = cursor as Record; - if (!Object.hasOwn(record, segment)) { - return false; - } - cursor = record[segment]; - } - return cursor !== undefined; -} - -function operationClobbersAncestorChild( - operation: ConfigSetOperation, - managedPath: ReadonlyArray, - options: { merge?: boolean }, -): boolean { - if (operation.mutation === "delete") { - return true; - } - const childPath = managedPath.slice(operation.requestedPath.length); - const isMerge = - operation.mutation === "merge" || (Boolean(options.merge) && operation.mutation !== "replace"); - if (isMerge) { - return valueHasAutoManagedChild(operation.value, childPath); - } - // Default set/replace at an ancestor path clobbers every descendant including - // the auto-managed leaf, even when the payload doesn't name it. - return true; -} - -function findAutoManagedMetaTargets( - operations: ReadonlyArray, - options: { merge?: boolean } = {}, -): readonly PathSegment[][] { - const matches: PathSegment[][] = []; - const seen = new Set(); - const record = (path: ReadonlyArray): void => { - const segments = [...path]; - const key = toDotPath(segments); - if (seen.has(key)) { - return; - } - seen.add(key); - matches.push(segments); - }; - for (const operation of operations) { - if (isAutoManagedMetaPath(operation.requestedPath)) { - record(operation.requestedPath); - continue; - } - for (const managedPath of AUTO_MANAGED_CONFIG_META_PATHS) { - if (operation.requestedPath.length >= managedPath.length) { - continue; - } - if (!pathStartsWith(managedPath, operation.requestedPath)) { - continue; - } - if (operationClobbersAncestorChild(operation, managedPath, options)) { - record(managedPath); - } - } - } - return matches; -} - -function findAutoManagedMetaUnsetTargets( - path: ReadonlyArray, -): readonly PathSegment[][] { - return findAutoManagedMetaTargets([ - { - inputMode: "json", - requestedPath: [...path], - setPath: [...path], - value: undefined, - mutation: "delete", - }, - ]); -} - -function formatAutoManagedMetaError(paths: readonly PathSegment[][]): string { - const targets = paths.map((path) => toDotPath(path)); - 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"); -} - -async function loadConfigMutationSchema(): Promise { - try { - return structuredClone((await readBestEffortRuntimeConfigSchema()).schema) as JsonSchemaRecord; - } catch { - return undefined; - } -} - -function collectDryRunSchemaErrors(params: { config: OpenClawConfig }): ConfigSetDryRunError[] { - const validated = validateConfigObjectRawWithPlugins(params.config); - if (validated.ok) { - return []; - } - return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({ - kind: "schema", - message, - })); -} - -function collectPluginIntegrationProviderErrors(params: { - config: OpenClawConfig; - operations: ConfigSetOperation[]; -}): ConfigSetDryRunError[] { - const providers = params.config.secrets?.providers ?? {}; - let validateAllProviders = false; - const touchedProviderAliases = new Set(); - 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); - } - if (touchesSecretProviderCollection(operation.setPath)) { - validateAllProviders = true; - } - } - 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)) { - continue; - } - if (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; -} - -function dedupeDryRunErrors(errors: ConfigSetDryRunError[]): ConfigSetDryRunError[] { - const deduped: ConfigSetDryRunError[] = []; - const seen = new Set(); - 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)) { - continue; - } - seen.add(key); - deduped.push(error); - } - return deduped; -} - -function formatDryRunFailureMessage(params: { - errors: ConfigSetDryRunError[]; - skippedExecRefs: number; -}): string { - const { errors, skippedExecRefs } = params; - const missingPathErrors = errors.filter((error) => error.kind === "missing-path"); - const schemaErrors = errors.filter((error) => error.kind === "schema"); - const resolveErrors = errors.filter((error) => error.kind === "resolvability"); - const modelErrors = errors.filter((error) => error.kind === "model"); - const lines: string[] = []; - if (missingPathErrors.length > 0) { - lines.push(...missingPathErrors.map((error) => error.message)); - } - if (schemaErrors.length > 0) { - lines.push("Dry run failed: config schema validation failed."); - lines.push(...schemaErrors.map((error) => `- ${error.message}`)); - } - if (resolveErrors.length > 0) { - lines.push( - `Dry run failed: ${resolveErrors.length} SecretRef assignment(s) could not be resolved.`, - ); - lines.push( - ...resolveErrors - .slice(0, 5) - .map((error) => `- ${error.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."); - lines.push(...modelErrors.map((error) => `- ${error.message}`)); - } - if (skippedExecRefs > 0) { - lines.push( - `Dry run note: skipped ${skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, - ); - } - return lines.join("\n"); -} - -async function runConfigOperations(params: { - runtime: RuntimeEnv; - operations: ConfigSetOperation[]; - options: ConfigMutationOptions; - successMode: "set" | "patch"; -}) { - const { runtime, operations, options } = params; - if ( - operations.some((operation) => - pathStartsWith(operation.requestedPath, PLUGIN_INSTALL_RECORD_PATH_PREFIX), - ) - ) { - throw new Error(formatPluginInstallConfigSetError()); - } - const autoManagedMetaTargets = findAutoManagedMetaTargets(operations, { - merge: options.merge, - }); - if (autoManagedMetaTargets.length > 0) { - throw new Error(formatAutoManagedMetaError(autoManagedMetaTargets)); - } - const snapshot = await loadValidConfig(runtime); - // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) - // instead of snapshot.config (runtime-merged with defaults). - // This prevents runtime defaults from leaking into the written config file (issue #6070) - const next = structuredClone(snapshot.resolved) as Record; - const currentConfigForApplyHint = normalizeConfigMutationModelRefs( - structuredClone(snapshot.resolved) as OpenClawConfig, - ); - const mutationSchema = await loadConfigMutationSchema(); - 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 policyIssues = collectUnsupportedSecretRefPolicyIssues(nextConfig); - const policyIssueLines = formatConfigIssueLines(policyIssues, "", { normalizeRoot: true }).map( - (line) => line.trim(), - ); - const pluginIntegrationProviderErrors = collectPluginIntegrationProviderErrors({ - config: nextConfig, - operations, - }); - - if (options.dryRun) { - const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); - const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); - const hasUnsetMode = operations.some((operation) => operation.inputMode === "unset"); - const requiresFullSchemaValidation = operations.some( - (operation) => - operation.inputMode === "unset" || - (operation.inputMode === "json" && operation.schemaValidated !== true), - ); - const refs = - hasJsonMode || hasBuilderMode || hasUnsetMode - ? collectDryRunRefs({ - config: nextConfig, - operations, - }) - : []; - const selectedDryRunRefs = selectDryRunRefsForResolution({ - refs, - allowExecInDryRun: Boolean(options.allowExec), - }); - const errors: ConfigSetDryRunError[] = []; - const modelRefCheck = await checkTouchedTextModelRefs({ - config: nextConfig, - previousConfig: currentConfigForApplyHint, - touchedPaths: operations.map((operation) => operation.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(...pluginIntegrationProviderErrors); - if (requiresFullSchemaValidation) { - errors.push( - ...collectDryRunSchemaErrors({ - config: nextConfig, - }), - ); - } - if (hasJsonMode || hasBuilderMode || hasUnsetMode) { - errors.push( - ...collectDryRunStaticErrorsForSkippedExecRefs({ - refs: selectedDryRunRefs.skippedExecRefs, - config: nextConfig, - }), - ); - errors.push( - ...(await collectDryRunResolvabilityErrors({ - refs: selectedDryRunRefs.refsToResolve, - config: nextConfig, - })), - ); - } - const dedupedErrors = dedupeDryRunErrors(errors); - const dryRunResult: ConfigSetDryRunResult = { - ok: dedupedErrors.length === 0, - operations: operations.length, - configPath: snapshot.path, - inputModes: uniqueValues(operations.map((operation) => operation.inputMode)), - checks: { - schema: - requiresFullSchemaValidation || - policyIssueLines.length > 0 || - pluginIntegrationProviderErrors.length > 0, - resolvability: hasJsonMode || hasBuilderMode || hasUnsetMode || modelRefCheck.refsTotal > 0, - resolvabilityComplete: - (hasJsonMode || hasBuilderMode || hasUnsetMode || modelRefCheck.refsTotal > 0) && - selectedDryRunRefs.skippedExecRefs.length === 0 && - modelRefCheck.refsChecked === modelRefCheck.refsTotal, - }, - refsChecked: selectedDryRunRefs.refsToResolve.length + modelRefCheck.refsChecked, - skippedExecRefs: selectedDryRunRefs.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: selectedDryRunRefs.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(formatUnsupportedSecretRefPolicyFailureMessage(policyIssueLines)); - } - if (pluginIntegrationProviderErrors.length > 0) { - throw new Error( - [ - "Config validation failed: plugin-managed SecretRef provider integration is invalid.", - ...pluginIntegrationProviderErrors.map((error) => `- ${error.message}`), - ].join("\n"), - ); - } - - const modelRefCheck = await checkTouchedTextModelRefs({ - config: nextConfig, - previousConfig: currentConfigForApplyHint, - touchedPaths: operations.map((operation) => operation.setPath), - redactDependencyValues: true, - }); - const firstModelError = modelRefCheck.errors[0]; - if (firstModelError) { - throw new Error(firstModelError); - } - - await replaceConfigFile({ - nextConfig, - ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - ...(unsetPaths.length > 0 || explicitSetPaths.length > 0 - ? { - writeOptions: { - auditOrigin: "cli", - ...(unsetPaths.length > 0 ? { unsetPaths } : {}), - ...(normalizedExplicitSetPaths.length > 0 - ? { explicitSetPaths: normalizedExplicitSetPaths } - : {}), - }, - } - : { writeOptions: { auditOrigin: "cli" } }), - }); - if (removedGatewayAuthPaths.length > 0) { - runtime.log( - info( - `Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${nextConfig.gateway?.auth?.mode ?? ""}.`, - ), - ); - } - if (params.successMode === "set" && operations.length === 1) { - const operation = operations[0]; - const action = operation?.mutation === "delete" ? "Removed" : "Updated"; - const hint = configApplyHintForOperations(operations, currentConfigForApplyHint, nextConfig); - runtime.log(info(`${action} ${toDotPath(operation?.requestedPath ?? [])}. ${hint}`)); - return; - } - const hint = configApplyHintForOperations(operations, currentConfigForApplyHint, nextConfig); - if (params.successMode === "set") { - runtime.log(info(`Updated ${operations.length} config paths. ${hint}`)); - return; - } - runtime.log(info(`Applied ${operations.length} config update(s). ${hint}`)); -} - -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); -} export async function runConfigSet(opts: { path?: string; @@ -2431,21 +97,17 @@ export async function runConfigSet(opts: { } const batchEntries = parseBatchSource(opts.cliOptions); - if (batchEntries) { - if (opts.path !== undefined || opts.value !== undefined) { - throw modeError("batch mode does not accept or arguments."); - } + if (batchEntries && (opts.path !== undefined || opts.value !== undefined)) { + throw modeError("batch mode does not accept or arguments."); } - const operations = batchEntries - ? parseBatchOperations(batchEntries) - : buildSingleSetOperations({ - path: opts.path, - value: opts.value, - opts: opts.cliOptions, - }); await runConfigOperations({ runtime, - operations, + operations: buildConfigSetOperations({ + path: opts.path, + value: opts.value, + opts: opts.cliOptions, + batchEntries: batchEntries ?? null, + }), options: opts.cliOptions, successMode: "set", }); @@ -2466,19 +128,10 @@ export async function runConfigPatch(opts: { if (opts.cliOptions.json && !opts.cliOptions.dryRun) { throw configPatchModeError("--json requires --dry-run."); } - const patch = await readConfigPatchInput(opts.cliOptions); - const operations = buildConfigPatchOperations({ - patch, - replacePaths: parseReplacePaths(opts.cliOptions.replacePath), - }); await runConfigOperations({ runtime, - operations, - options: { - dryRun: opts.cliOptions.dryRun, - allowExec: opts.cliOptions.allowExec, - json: opts.cliOptions.json, - }, + operations: await readConfigPatchOperations(opts.cliOptions), + options: opts.cliOptions, successMode: "patch", }); } catch (err) { @@ -2491,8 +144,7 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime try { const parsedPath = parseConfigSetPath(opts.path); const snapshot = await loadValidConfig(runtime); - const redacted = redactConfigObject(snapshot.config); - const res = getAtPath(redacted, parsedPath); + const res = getAtPath(redactConfigObject(snapshot.config), parsedPath); if (!res.found) { if (opts.json) { writeRuntimeJson(runtime, { error: `Config path not found: ${opts.path}` }); @@ -2509,17 +161,15 @@ export async function runConfigGet(opts: { path: string; json?: boolean; runtime } if (opts.json) { writeRuntimeJson(runtime, res.value ?? null); - return; - } - if ( + } else if ( typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean" ) { runtime.log(String(res.value)); - return; + } else { + writeRuntimeJson(runtime, res.value ?? null); } - writeRuntimeJson(runtime, res.value ?? null); } catch (err) { if (err instanceof ExitError) { throw err; @@ -2544,16 +194,11 @@ export async function runConfigUnset(opts: { throw new Error("--json can only be used with --dry-run."); } const parsedPath = parseConfigSetPath(opts.path); - const autoManagedUnsetTargets = findAutoManagedMetaUnsetTargets(parsedPath); - if (autoManagedUnsetTargets.length > 0) { - throw new Error(formatAutoManagedMetaError(autoManagedUnsetTargets)); - } + assertConfigPathIsNotAutoManaged(parsedPath); const snapshot = await loadValidConfig(runtime); - // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) - // instead of snapshot.config (runtime-merged with defaults). - // This prevents runtime defaults from leaking into the written config file (issue #6070) + // Mutate resolved config so runtime defaults never leak into the authored file. const next = structuredClone(snapshot.resolved) as Record; - const currentConfigForApplyHint = normalizeConfigMutationModelRefs( + const currentConfig = normalizeConfigMutationModelRefs( structuredClone(snapshot.resolved) as OpenClawConfig, ); const unsetResult = unsetAtPath(next, parsedPath); @@ -2569,11 +214,7 @@ export async function runConfigUnset(opts: { operations: 1, configPath: snapshot.path, inputModes: ["unset"], - checks: { - schema: false, - resolvability: false, - resolvabilityComplete: false, - }, + checks: { schema: false, resolvability: false, resolvabilityComplete: false }, refsChecked: 0, skippedExecRefs: 0, errors: [ @@ -2590,10 +231,11 @@ export async function runConfigUnset(opts: { runtime.exit(1); return; } + const operation = buildUnsetOperation(parsedPath); if (cliOptions.dryRun) { await runConfigOperations({ runtime, - operations: [buildUnsetOperation(parsedPath)], + operations: [operation], options: cliOptions, successMode: "set", }); @@ -2602,26 +244,22 @@ export async function runConfigUnset(opts: { const nextConfig = normalizeConfigMutationModelRefs(structuredClone(next) as OpenClawConfig); const modelRefCheck = await checkTouchedTextModelRefs({ config: nextConfig, - previousConfig: currentConfigForApplyHint, + previousConfig: currentConfig, touchedPaths: [parsedPath], redactDependencyValues: true, }); - const firstModelError = modelRefCheck.errors[0]; - if (firstModelError) { - throw new Error(firstModelError); + if (modelRefCheck.errors[0]) { + throw new Error(modelRefCheck.errors[0]); } await replaceConfigFile({ nextConfig, ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - ...(unsetResult.leafContainer === "array" - ? { writeOptions: { auditOrigin: "cli" } } - : { writeOptions: { auditOrigin: "cli", unsetPaths: [parsedPath] } }), + writeOptions: + unsetResult.leafContainer === "array" + ? { auditOrigin: "cli" } + : { auditOrigin: "cli", unsetPaths: [parsedPath] }, }); - const hint = configApplyHintForOperations( - [buildUnsetOperation(parsedPath)], - currentConfigForApplyHint, - nextConfig, - ); + const hint = configApplyHintForOperations([operation], currentConfig, nextConfig); runtime.log(info(`Removed ${opts.path}. ${hint}`)); } catch (err) { handleConfigMutationError({ err, runtime, options: cliOptions }); @@ -2638,24 +276,14 @@ async function runConfigFile(opts: { runtime?: RuntimeEnv }) { } } -async function buildCliConfigSchema(): Promise> { - const schema = structuredClone((await readBestEffortRuntimeConfigSchema()).schema) as { - properties?: Record; - required?: string[]; - }; - - schema.properties = { - $schema: { type: "string" }, - ...schema.properties, - }; - - return schema; -} - async function runConfigSchema(opts: { runtime?: RuntimeEnv } = {}) { const runtime = opts.runtime ?? defaultRuntime; try { - writeRuntimeJson(runtime, await buildCliConfigSchema()); + const schema = structuredClone((await readBestEffortRuntimeConfigSchema()).schema) as { + properties?: Record; + }; + schema.properties = { $schema: { type: "string" }, ...schema.properties }; + writeRuntimeJson(runtime, schema); } catch (err) { runtime.error(danger(`Config schema error: ${String(err)}`)); runtime.exit(1); @@ -2665,12 +293,10 @@ async function runConfigSchema(opts: { runtime?: RuntimeEnv } = {}) { async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) { const runtime = opts.runtime ?? defaultRuntime; let outputPath = CONFIG_PATH ?? "openclaw.json"; - try { const snapshot = await readConfigFileSnapshot(); outputPath = snapshot.path; const shortPath = shortenHomePath(outputPath); - if (!snapshot.exists) { if (opts.json) { writeRuntimeJson(runtime, { valid: false, path: outputPath, error: "file not found" }, 0); @@ -2683,10 +309,8 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } runtime.exit(1); return; } - if (!snapshot.valid) { const issues = normalizeConfigIssues(snapshot.issues); - if (opts.json) { writeRuntimeJson(runtime, { valid: false, path: outputPath, issues }); } else { @@ -2713,7 +337,6 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } runtime.exit(1); return; } - const warnings = normalizeConfigIssues(snapshot.warnings); if (opts.json) { writeRuntimeJson(runtime, { valid: true, path: outputPath, warnings }, 0); @@ -2736,6 +359,10 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } } } +function collectOption(value: string, previous: string[]): string[] { + return [...previous, value]; +} + export function registerConfigCli(program: Command) { const cmd = program .command("config") @@ -2750,7 +377,7 @@ export function registerConfigCli(program: Command) { .option( "--section
", "Configuration sections for guided setup (repeatable). Use with no subcommand.", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .action(async (opts) => { @@ -2796,7 +423,7 @@ export function registerConfigCli(program: Command) { .option( "--provider-allowlist ", "Provider builder (env): allowlist entry (repeatable)", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .option("--provider-path ", "Provider builder (file): path") @@ -2807,7 +434,7 @@ export function registerConfigCli(program: Command) { .option( "--provider-arg ", "Provider builder (exec): command arg (repeatable)", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .option("--provider-no-output-timeout-ms ", "Provider builder (exec): no-output timeout ms") @@ -2816,19 +443,19 @@ export function registerConfigCli(program: Command) { .option( "--provider-env ", "Provider builder (exec): env assignment (repeatable)", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .option( "--provider-pass-env ", "Provider builder (exec): pass host env var (repeatable)", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .option( "--provider-trusted-dir ", "Provider builder (exec): trusted directory (repeatable)", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .option( @@ -2844,11 +471,7 @@ export function registerConfigCli(program: Command) { .option("--batch-json ", "Batch mode: JSON array of set operations") .option("--batch-file ", "Batch mode: read JSON array of set operations from file") .action(async (path: string | undefined, value: string | undefined, opts: ConfigSetOptions) => { - await runConfigSet({ - path, - value, - cliOptions: opts, - }); + await runConfigSet({ path, value, cliOptions: opts }); }); cmd @@ -2870,7 +493,7 @@ export function registerConfigCli(program: Command) { .option( "--replace-path ", "Replace the object or array at this dot/bracket path instead of recursively applying it (repeatable)", - (value: string, previous: string[]) => [...previous, value], + collectOption, [] as string[], ) .action(async (opts: ConfigPatchOptions) => { @@ -2888,20 +511,11 @@ export function registerConfigCli(program: Command) { await runConfigUnset({ path, cliOptions: options }); }); - cmd - .command("file") - .description("Print the active config file path") - .action(async () => { - await runConfigFile({}); - }); - + cmd.command("file").description("Print the active config file path").action(runConfigFile); cmd .command("schema") .description("Print the JSON schema for openclaw.json") - .action(async () => { - await runConfigSchema({}); - }); - + .action(runConfigSchema); cmd .command("validate") .description("Validate the current config against the schema without starting the gateway") @@ -2910,4 +524,3 @@ export function registerConfigCli(program: Command) { await runConfigValidate({ json: Boolean(opts.json) }); }); } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */