import type { Command } from "commander"; import JSON5 from "json5"; import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js"; import type { OpenClawConfig } from "../config/config.js"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js"; import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; import { coerceSecretRef, isValidEnvSecretRefId, resolveSecretInputRef, type SecretProviderConfig, type SecretRef, type SecretRefSource, } from "../config/types.secrets.js"; import { validateConfigObjectRaw } from "../config/validation.js"; import { SecretProviderSchema } from "../config/zod-schema.core.js"; import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.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 { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; import type { ConfigSetDryRunError, ConfigSetDryRunInputMode, ConfigSetDryRunResult, } from "./config-set-dryrun.js"; import { hasBatchMode, hasProviderBuilderOptions, hasRefBuilderOptions, parseBatchSource, type ConfigSetBatchEntry, type ConfigSetOptions, } from "./config-set-input.js"; import { resolveConfigSetMode } from "./config-set-parser.js"; type PathSegment = string; type ConfigSetParseOpts = { strictJson?: boolean; }; type ConfigSetInputMode = ConfigSetDryRunInputMode; type ConfigSetOperation = { inputMode: ConfigSetInputMode; requestedPath: PathSegment[]; setPath: PathSegment[]; value: unknown; touchedSecretTargetPath?: string; touchedProviderAlias?: string; assignedRef?: SecretRef; }; const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"]; 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_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, ].join("\n"); class ConfigSetDryRunValidationError extends Error { constructor(readonly result: ConfigSetDryRunResult) { super("config set dry-run validation failed"); this.name = "ConfigSetDryRunValidationError"; } } function isIndexSegment(raw: string): boolean { return /^[0-9]+$/.test(raw); } function parsePath(raw: string): PathSegment[] { const trimmed = raw.trim(); if (!trimmed) { return []; } const parts: string[] = []; let current = ""; 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 === ".") { if (current) { parts.push(current); } current = ""; i += 1; continue; } if (ch === "[") { 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(inside); i = close + 1; continue; } current += ch; i += 1; } if (current) { parts.push(current); } return parts.map((part) => part.trim()).filter(Boolean); } function parseValue(raw: string, opts: ConfigSetParseOpts): unknown { const trimmed = raw.trim(); if (opts.strictJson) { try { return JSON5.parse(trimmed); } catch (err) { throw new Error(`Failed to parse JSON5 value: ${String(err)}`, { cause: err }); } } try { return JSON5.parse(trimmed); } catch { return raw; } } function hasOwnPathKey(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } function formatDoctorHint(message: string): string { return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`; } 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 = Number.parseInt(segment, 10); if (!Number.isFinite(index) || index < 0 || 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 setAtPath(root: Record, path: PathSegment[], value: unknown): void { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { const segment = path[i]; const next = path[i + 1]; const nextIsIndex = Boolean(next && isIndexSegment(next)); if (Array.isArray(current)) { if (!isIndexSegment(segment)) { throw new Error(`Expected numeric index for array segment "${segment}"`); } const index = Number.parseInt(segment, 10); 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]; } const last = path[path.length - 1]; if (Array.isArray(current)) { if (!isIndexSegment(last)) { throw new Error(`Expected numeric index for array segment "${last}"`); } const index = Number.parseInt(last, 10); 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 unsetAtPath(root: Record, path: PathSegment[]): boolean { let current: unknown = root; for (let i = 0; i < path.length - 1; i += 1) { const segment = path[i]; if (!current || typeof current !== "object") { return false; } if (Array.isArray(current)) { if (!isIndexSegment(segment)) { return false; } const index = Number.parseInt(segment, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { return false; } current = current[index]; continue; } const record = current as Record; if (!hasOwnPathKey(record, segment)) { return false; } current = record[segment]; } const last = path[path.length - 1]; if (Array.isArray(current)) { if (!isIndexSegment(last)) { return false; } const index = Number.parseInt(last, 10); if (!Number.isFinite(index) || index < 0 || index >= current.length) { return false; } current.splice(index, 1); return true; } if (!current || typeof current !== "object") { return false; } const record = current as Record; if (!hasOwnPathKey(record, last)) { return false; } delete record[last]; return true; } async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) { const snapshot = await readConfigFileSnapshot(); if (snapshot.valid) { return snapshot; } runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`); for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) { runtime.error(line); } runtime.error(formatDoctorHint("to repair, then retry.")); runtime.exit(1); return snapshot; } function parseRequiredPath(path: string): PathSegment[] { 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 ensureValidOllamaProviderForApiKeySet( root: Record, path: PathSegment[], ): void { if (!pathEquals(path, OLLAMA_API_KEY_PATH)) { return; } const existing = getAtPath(root, OLLAMA_PROVIDER_PATH); if (existing.found) { return; } setAtPath(root, OLLAMA_PROVIDER_PATH, { baseUrl: OLLAMA_DEFAULT_BASE_URL, api: "ollama", models: [], }); } function toDotPath(path: PathSegment[]): string { return path.join("."); } 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 = Number(trimmed); if (!Number.isInteger(parsed) || parsed <= 0) { 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 = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean); 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 } : {}), }; } 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: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) } : {}), ...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0 ? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) } : {}), ...(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, 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, 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 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 = parseRequiredPath(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, touchedProviderAlias: alias, }); continue; } operations.push( buildValueAssignmentOperation({ requestedPath: path, value: entry.value, inputMode: "json", }), ); } return operations; } 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 ? parseRequiredPath(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, 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(); for (const operation of params.operations) { if (operation.assignedRef) { refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef); } if (operation.touchedSecretTargetPath) { targetPaths.add(operation.touchedSecretTargetPath); } if (operation.touchedProviderAlias) { providerAliases.add(operation.touchedProviderAlias); } } if (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 (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 collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] { const validated = validateConfigObjectRaw(config); if (validated.ok) { return []; } return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({ kind: "schema", message, })); } function formatDryRunFailureMessage(params: { errors: ConfigSetDryRunError[]; skippedExecRefs: number; }): string { const { errors, skippedExecRefs } = params; const schemaErrors = errors.filter((error) => error.kind === "schema"); const resolveErrors = errors.filter((error) => error.kind === "resolvability"); const lines: string[] = []; 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 (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"); } export async function runConfigSet(opts: { path?: string; value?: string; cliOptions: ConfigSetOptions; runtime?: RuntimeEnv; }) { const runtime = opts.runtime ?? defaultRuntime; try { const isBatchMode = hasBatchMode(opts.cliOptions); const modeResolution = resolveConfigSetMode({ hasBatchMode: isBatchMode, hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions), hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions), strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json), }); if (!modeResolution.ok) { throw modeError(modeResolution.error); } if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { throw modeError("--allow-exec requires --dry-run."); } const batchEntries = parseBatchSource(opts.cliOptions); if (batchEntries) { if (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, }); 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; for (const operation of operations) { ensureValidOllamaProviderForApiKeySet(next, operation.setPath); setAtPath(next, operation.setPath, operation.value); } const nextConfig = next as OpenClawConfig; if (opts.cliOptions.dryRun) { const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); const refs = hasJsonMode || hasBuilderMode ? collectDryRunRefs({ config: nextConfig, operations, }) : []; const selectedDryRunRefs = selectDryRunRefsForResolution({ refs, allowExecInDryRun: Boolean(opts.cliOptions.allowExec), }); const errors: ConfigSetDryRunError[] = []; if (hasJsonMode) { errors.push(...collectDryRunSchemaErrors(nextConfig)); } if (hasJsonMode || hasBuilderMode) { errors.push( ...collectDryRunStaticErrorsForSkippedExecRefs({ refs: selectedDryRunRefs.skippedExecRefs, config: nextConfig, }), ); errors.push( ...(await collectDryRunResolvabilityErrors({ refs: selectedDryRunRefs.refsToResolve, config: nextConfig, })), ); } const dryRunResult: ConfigSetDryRunResult = { ok: errors.length === 0, operations: operations.length, configPath: shortenHomePath(snapshot.path), inputModes: [...new Set(operations.map((operation) => operation.inputMode))], checks: { schema: hasJsonMode, resolvability: hasJsonMode || hasBuilderMode, resolvabilityComplete: (hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0, }, refsChecked: selectedDryRunRefs.refsToResolve.length, skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, ...(errors.length > 0 ? { errors } : {}), }; if (errors.length > 0) { if (opts.cliOptions.json) { throw new ConfigSetDryRunValidationError(dryRunResult); } throw new Error( formatDryRunFailureMessage({ errors, skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, }), ); } if (opts.cliOptions.json) { runtime.log(JSON.stringify(dryRunResult, null, 2)); } 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; } await writeConfigFile(next); if (operations.length === 1) { runtime.log( info( `Updated ${toDotPath(operations[0]?.requestedPath ?? [])}. Restart the gateway to apply.`, ), ); return; } runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`)); } catch (err) { if ( opts.cliOptions.dryRun && opts.cliOptions.json && err instanceof ConfigSetDryRunValidationError ) { runtime.log(JSON.stringify(err.result, null, 2)); runtime.exit(1); return; } runtime.error(danger(String(err))); runtime.exit(1); } } export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { const parsedPath = parseRequiredPath(opts.path); const snapshot = await loadValidConfig(runtime); const redacted = redactConfigObject(snapshot.config); const res = getAtPath(redacted, parsedPath); if (!res.found) { runtime.error(danger(`Config path not found: ${opts.path}`)); runtime.exit(1); return; } if (opts.json) { runtime.log(JSON.stringify(res.value ?? null, null, 2)); return; } if ( typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean" ) { runtime.log(String(res.value)); return; } runtime.log(JSON.stringify(res.value ?? null, null, 2)); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); } } export async function runConfigUnset(opts: { path: string; runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { const parsedPath = parseRequiredPath(opts.path); 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 removed = unsetAtPath(next, parsedPath); if (!removed) { runtime.error(danger(`Config path not found: ${opts.path}`)); runtime.exit(1); return; } await writeConfigFile(next, { unsetPaths: [parsedPath] }); runtime.log(info(`Removed ${opts.path}. Restart the gateway to apply.`)); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); } } export async function runConfigFile(opts: { runtime?: RuntimeEnv }) { const runtime = opts.runtime ?? defaultRuntime; try { const snapshot = await readConfigFileSnapshot(); runtime.log(shortenHomePath(snapshot.path)); } catch (err) { runtime.error(danger(String(err))); runtime.exit(1); } } export 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) { runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" })); } else { runtime.error(danger(`Config file not found: ${shortPath}`)); } runtime.exit(1); return; } if (!snapshot.valid) { const issues = normalizeConfigIssues(snapshot.issues); if (opts.json) { runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2)); } else { runtime.error(danger(`Config invalid at ${shortPath}:`)); for (const line of formatConfigIssueLines(issues, danger("×"), { normalizeRoot: true })) { runtime.error(` ${line}`); } runtime.error(""); runtime.error(formatDoctorHint("to repair, or fix the keys above manually.")); } runtime.exit(1); return; } if (opts.json) { runtime.log(JSON.stringify({ valid: true, path: outputPath })); } else { runtime.log(success(`Config valid: ${shortPath}`)); } } catch (err) { if (opts.json) { runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) })); } else { runtime.error(danger(`Config validation error: ${String(err)}`)); } runtime.exit(1); } } export function registerConfigCli(program: Command) { const cmd = program .command("config") .description( "Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for guided setup.", ) .addHelpText( "after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/config", "docs.openclaw.ai/cli/config")}\n`, ) .option( "--section
", "Configuration sections for guided setup (repeatable). Use with no subcommand.", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .action(async (opts) => { const { configureCommandFromSectionsArg } = await import("../commands/configure.js"); await configureCommandFromSectionsArg(opts.section, defaultRuntime); }); cmd .command("get") .description("Get a config value by dot path") .argument("", "Config path (dot or bracket notation)") .option("--json", "Output JSON", false) .action(async (path: string, opts) => { await runConfigGet({ path, json: Boolean(opts.json) }); }); cmd .command("set") .description(CONFIG_SET_DESCRIPTION) .argument("[path]", "Config path (dot or bracket notation)") .argument("[value]", "Value (JSON5 or raw string)") .option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false) .option("--json", "Legacy alias for --strict-json", false) .option( "--dry-run", "Validate changes without writing openclaw.json (checks run in builder/json/batch modes; exec SecretRefs are skipped unless --allow-exec is set)", false, ) .option( "--allow-exec", "Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)", false, ) .option("--ref-provider ", "SecretRef builder: provider alias") .option("--ref-source ", "SecretRef builder: source (env|file|exec)") .option("--ref-id ", "SecretRef builder: ref id") .option("--provider-source ", "Provider builder: source (env|file|exec)") .option( "--provider-allowlist ", "Provider builder (env): allowlist entry (repeatable)", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .option("--provider-path ", "Provider builder (file): path") .option("--provider-mode ", "Provider builder (file): mode (singleValue|json)") .option("--provider-timeout-ms ", "Provider builder (file|exec): timeout ms") .option("--provider-max-bytes ", "Provider builder (file): max bytes") .option("--provider-command ", "Provider builder (exec): absolute command path") .option( "--provider-arg ", "Provider builder (exec): command arg (repeatable)", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .option("--provider-no-output-timeout-ms ", "Provider builder (exec): no-output timeout ms") .option("--provider-max-output-bytes ", "Provider builder (exec): max output bytes") .option("--provider-json-only", "Provider builder (exec): require JSON output", false) .option( "--provider-env ", "Provider builder (exec): env assignment (repeatable)", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .option( "--provider-pass-env ", "Provider builder (exec): pass host env var (repeatable)", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .option( "--provider-trusted-dir ", "Provider builder (exec): trusted directory (repeatable)", (value: string, previous: string[]) => [...previous, value], [] as string[], ) .option( "--provider-allow-insecure-path", "Provider builder (exec): bypass strict path permission checks", false, ) .option( "--provider-allow-symlink-command", "Provider builder (exec): allow command symlink path", false, ) .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, }); }); cmd .command("unset") .description("Remove a config value by dot path") .argument("", "Config path (dot or bracket notation)") .action(async (path: string) => { await runConfigUnset({ path }); }); cmd .command("file") .description("Print the active config file path") .action(async () => { await runConfigFile({}); }); cmd .command("validate") .description("Validate the current config against the schema without starting the gateway") .option("--json", "Output validation result as JSON", false) .action(async (opts) => { await runConfigValidate({ json: Boolean(opts.json) }); }); }