diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 69ba866534e..582cd9fd2d3 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -513,6 +513,26 @@ describe("config cli", () => { ); }); + it("logs a dry-run note when value mode performs no validation checks", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "set", "gateway.port", "19001", "--dry-run"]); + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining( + "Dry run note: value mode does not run schema/resolvability checks.", + ), + ); + expect(mockLog).toHaveBeenCalledWith( + expect.stringContaining("Dry run successful: 1 update(s) validated"), + ); + }); + it("supports batch mode for refs/providers in dry-run", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 }, @@ -862,6 +882,56 @@ describe("config cli", () => { ); expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); }); + + it("fails dry-run for nested provider edits that make existing refs unresolvable", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + secrets: { + providers: { + vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" }, + }, + }, + tools: { + web: { + search: { + enabled: true, + apiKey: { + source: "file", + provider: "vaultfile", + id: "/providers/search/apiKey", + }, + }, + }, + } as never, + }; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockImplementationOnce(async () => { + throw new Error("provider mismatch"); + }); + + await expect( + runConfigCommand([ + "config", + "set", + "secrets.providers.vaultfile.path", + '"/tmp/other-secrets.json"', + "--strict-json", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "vaultfile", + id: "/providers/search/apiKey", + }), + expect.any(Object), + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."), + ); + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch")); + }); }); describe("path hardening", () => { diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index f7efaf1c865..0da785a2fd8 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -42,6 +42,7 @@ import type { ConfigSetDryRunResult, } from "./config-set-dryrun.js"; import { + hasBatchMode, hasProviderBuilderOptions, hasRefBuilderOptions, parseBatchSource, @@ -593,7 +594,7 @@ function buildRefAssignmentOperation(params: { function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null { if ( - path.length === 3 && + path.length >= 3 && path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && path[1] === SECRET_PROVIDER_PATH_PREFIX[1] ) { @@ -857,12 +858,9 @@ export async function runConfigSet(opts: { }) { const runtime = opts.runtime ?? defaultRuntime; try { - const hasBatchMode = Boolean( - (opts.cliOptions.batchJson && opts.cliOptions.batchJson.trim().length > 0) || - (opts.cliOptions.batchFile && opts.cliOptions.batchFile.trim().length > 0), - ); + const isBatchMode = hasBatchMode(opts.cliOptions); const modeResolution = resolveConfigSetMode({ - hasBatchMode, + hasBatchMode: isBatchMode, hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions), hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions), strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json), @@ -938,6 +936,13 @@ export async function runConfigSet(opts: { 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.", + ), + ); + } runtime.log( info( `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, @@ -1126,7 +1131,11 @@ export function registerConfigCli(program: Command) { .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", false) + .option( + "--dry-run", + "Validate changes without writing openclaw.json (checks run in builder/json/batch modes)", + false, + ) .option("--ref-provider ", "SecretRef builder: provider alias") .option("--ref-source ", "SecretRef builder: source (env|file|exec)") .option("--ref-id ", "SecretRef builder: ref id")