CLI: skip exec SecretRef dry-run resolution unless explicitly allowed (#49322)

* CLI: gate exec SecretRef dry-run resolution behind opt-in

* Docs: clarify config dry-run exec opt-in behavior

* CLI: preserve static exec dry-run validation
This commit is contained in:
Josh Avant
2026-03-17 20:20:11 -05:00
committed by GitHub
parent 9a455a8c08
commit 2d3bcbfe08
7 changed files with 495 additions and 14 deletions

View File

@@ -176,19 +176,31 @@ openclaw config set channels.discord.token \
--ref-id DISCORD_BOT_TOKEN \
--dry-run \
--json
openclaw config set channels.discord.token \
--ref-provider vault \
--ref-source exec \
--ref-id discord/token \
--dry-run \
--allow-exec
```
Dry-run behavior:
- Builder mode: requires full SecretRef resolvability for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation.
- Builder mode: runs SecretRef resolvability checks for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): runs schema validation plus SecretRef resolvability checks.
- Exec SecretRef checks are skipped by default during dry-run to avoid command side effects.
- Use `--allow-exec` with `--dry-run` to opt in to exec SecretRef checks (this may execute provider commands).
- `--allow-exec` is dry-run only and errors if used without `--dry-run`.
`--dry-run --json` prints a machine-readable report:
- `ok`: whether dry-run passed
- `operations`: number of assignments evaluated
- `checks`: whether schema/resolvability checks ran
- `refsChecked`: number of refs resolved during dry-run
- `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped)
- `refsChecked`: number of refs actually resolved during dry-run
- `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set
- `errors`: structured schema/resolvability failures when `ok=false`
### JSON Output Shape
@@ -202,8 +214,10 @@ Dry-run behavior:
checks: {
schema: boolean,
resolvability: boolean,
resolvabilityComplete: boolean,
},
refsChecked: number,
skippedExecRefs: number,
errors?: [
{
kind: "schema" | "resolvability",
@@ -224,9 +238,11 @@ Success example:
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1
"refsChecked": 1,
"skippedExecRefs": 0
}
```
@@ -240,9 +256,11 @@ Failure example:
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true
"resolvability": true,
"resolvabilityComplete": true
},
"refsChecked": 1,
"skippedExecRefs": 0,
"errors": [
{
"kind": "resolvability",
@@ -257,6 +275,7 @@ If dry-run fails:
- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape.
- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch).
- `Dry run note: skipped <n> exec SecretRef resolvability check(s)`: dry-run skipped exec refs; rerun with `--allow-exec` if you need exec resolvability validation.
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
## Subcommands

View File

@@ -400,8 +400,9 @@ Subcommands:
- SecretRef builder mode: `config set <path> --ref-provider <provider> --ref-source <source> --ref-id <id>`
- provider builder mode: `config set secrets.providers.<alias> --provider-source <env|file|exec> ...`
- batch mode: `config set --batch-json '<json>'` or `config set --batch-file <path>`
- `config set --dry-run`: validate assignments without writing `openclaw.json`.
- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors).
- `config set --dry-run`: validate assignments without writing `openclaw.json` (exec SecretRef checks are skipped by default).
- `config set --allow-exec --dry-run`: opt in to exec SecretRef dry-run checks (may execute provider commands).
- `config set --dry-run --json`: emit machine-readable dry-run output (checks + completeness signal, operations, refs checked/skipped, errors).
- `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode.
- `config unset <path>`: remove a value.
- `config file`: print the active config file path.

View File

@@ -23,6 +23,39 @@ function createTestRuntime() {
};
}
function createExecDryRunBatch(params: { markerPath: string }) {
const response = JSON.stringify({
protocolVersion: 1,
values: {
dryrun_id: "ok",
},
});
const script = [
'const fs = require("node:fs");',
`fs.writeFileSync(${JSON.stringify(params.markerPath)}, "dryrun\\n", "utf8");`,
`process.stdout.write(${JSON.stringify(response)});`,
].join("");
return [
{
path: "secrets.providers.runner",
provider: {
source: "exec",
command: process.execPath,
args: ["-e", script],
allowInsecurePath: true,
},
},
{
path: "channels.discord.token",
ref: {
source: "exec",
provider: "runner",
id: "dryrun_id",
},
},
];
}
describe("config cli integration", () => {
it("supports batch-file dry-run and then writes real config changes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-"));
@@ -183,4 +216,115 @@ describe("config cli integration", () => {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("skips exec provider execution during dry-run by default", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-skip-"));
const configPath = path.join(tempDir, "openclaw.json");
const batchPath = path.join(tempDir, "batch.json");
const markerPath = path.join(tempDir, "marker.txt");
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]);
try {
fs.writeFileSync(
configPath,
`${JSON.stringify(
{
gateway: { port: 18789 },
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
batchPath,
`${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`,
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
clearConfigCache();
clearRuntimeConfigSnapshot();
const runtime = createTestRuntime();
const before = fs.readFileSync(configPath, "utf8");
await runConfigSet({
cliOptions: {
batchFile: batchPath,
dryRun: true,
},
runtime: runtime.runtime,
});
const after = fs.readFileSync(configPath, "utf8");
expect(after).toBe(before);
expect(fs.existsSync(markerPath)).toBe(false);
expect(
runtime.logs.some((line) =>
line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."),
),
).toBe(true);
} finally {
envSnapshot.restore();
clearConfigCache();
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("executes exec providers during dry-run when --allow-exec is set", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-exec-allow-"));
const configPath = path.join(tempDir, "openclaw.json");
const batchPath = path.join(tempDir, "batch.json");
const markerPath = path.join(tempDir, "marker.txt");
const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]);
try {
fs.writeFileSync(
configPath,
`${JSON.stringify(
{
gateway: { port: 18789 },
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
batchPath,
`${JSON.stringify(createExecDryRunBatch({ markerPath }), null, 2)}\n`,
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
clearConfigCache();
clearRuntimeConfigSnapshot();
const runtime = createTestRuntime();
const before = fs.readFileSync(configPath, "utf8");
await runConfigSet({
cliOptions: {
batchFile: batchPath,
dryRun: true,
allowExec: true,
},
runtime: runtime.runtime,
});
const after = fs.readFileSync(configPath, "utf8");
expect(after).toBe(before);
expect(fs.existsSync(markerPath)).toBe(true);
expect(
runtime.logs.some((line) =>
line.includes("Dry run note: skipped 1 exec SecretRef resolvability check(s)."),
),
).toBe(false);
} finally {
envSnapshot.restore();
clearConfigCache();
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -386,6 +386,7 @@ describe("config cli", () => {
expect(helpText).toContain("--provider-source");
expect(helpText).toContain("--batch-json");
expect(helpText).toContain("--dry-run");
expect(helpText).toContain("--allow-exec");
expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json");
expect(helpText).toContain(
"openclaw config set channels.discord.token --ref-provider default --ref-source",
@@ -556,6 +557,169 @@ describe("config cli", () => {
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
});
it("skips exec SecretRef resolvability checks in dry-run by default", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "exec",
command: "/usr/bin/env",
allowInsecurePath: true,
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockLog).toHaveBeenCalledWith(
expect.stringContaining(
"Dry run note: skipped 1 exec SecretRef resolvability check(s). Re-run with --allow-exec",
),
);
});
it("allows exec SecretRef resolvability checks in dry-run when --allow-exec is set", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "exec",
command: "/usr/bin/env",
allowInsecurePath: true,
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
"--allow-exec",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
expect.objectContaining({
source: "exec",
provider: "runner",
id: "openai",
}),
expect.any(Object),
);
expect(mockLog).not.toHaveBeenCalledWith(
expect.stringContaining("Dry run note: skipped 1 exec SecretRef resolvability check(s)."),
);
});
it("rejects --allow-exec without --dry-run", async () => {
const nonexistentBatchPath = path.join(
os.tmpdir(),
`openclaw-config-batch-nonexistent-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
await expect(
runConfigCommand(["config", "set", "--batch-file", nonexistentBatchPath, "--allow-exec"]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("config set mode error: --allow-exec requires --dry-run."),
);
});
it("fails dry-run when skipped exec refs use an unconfigured provider", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining('Secret provider "runner" is not configured'),
);
});
it("fails dry-run when skipped exec refs use a provider with mismatched source", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "env",
},
},
},
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockResolveSecretRefValue).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining(
'Secret provider "runner" has source "env" but ref requests "exec".',
),
);
});
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
@@ -749,19 +913,66 @@ describe("config cli", () => {
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
checks: { schema: boolean; resolvability: boolean };
checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean };
refsChecked: number;
skippedExecRefs: number;
operations: number;
};
expect(payload.ok).toBe(true);
expect(payload.operations).toBe(1);
expect(payload.refsChecked).toBe(1);
expect(payload.skippedExecRefs).toBe(0);
expect(payload.checks).toEqual({
schema: false,
resolvability: true,
resolvabilityComplete: true,
});
});
it("emits skipped exec metadata for --dry-run --json success", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
runner: {
source: "exec",
command: "/usr/bin/env",
allowInsecurePath: true,
},
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"runner",
"--ref-source",
"exec",
"--ref-id",
"openai",
"--dry-run",
"--json",
]);
const raw = mockLog.mock.calls.at(-1)?.[0];
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
checks: { resolvability: boolean; resolvabilityComplete: boolean };
refsChecked: number;
skippedExecRefs: number;
};
expect(payload.ok).toBe(true);
expect(payload.checks.resolvability).toBe(true);
expect(payload.checks.resolvabilityComplete).toBe(false);
expect(payload.refsChecked).toBe(0);
expect(payload.skippedExecRefs).toBe(1);
});
it("emits structured JSON for --dry-run --json failure", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },

View File

@@ -22,6 +22,7 @@ import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
isValidFileSecretRefId,
isValidSecretProviderAlias,
secretRefKey,
@@ -815,6 +816,66 @@ async function collectDryRunResolvabilityErrors(params: {
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) {
@@ -826,7 +887,11 @@ function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError
}));
}
function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string {
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[] = [];
@@ -847,6 +912,11 @@ function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string {
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");
}
@@ -868,6 +938,9 @@ export async function runConfigSet(opts: {
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) {
@@ -903,14 +976,24 @@ export async function runConfigSet(opts: {
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,
refs: selectedDryRunRefs.refsToResolve,
config: nextConfig,
})),
);
@@ -923,15 +1006,23 @@ export async function runConfigSet(opts: {
checks: {
schema: hasJsonMode,
resolvability: hasJsonMode || hasBuilderMode,
resolvabilityComplete:
(hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0,
},
refsChecked: refs.length,
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));
throw new Error(
formatDryRunFailureMessage({
errors,
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
}),
);
}
if (opts.cliOptions.json) {
runtime.log(JSON.stringify(dryRunResult, null, 2));
@@ -943,6 +1034,13 @@ export async function runConfigSet(opts: {
),
);
}
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)}.`,
@@ -1133,7 +1231,12 @@ export function registerConfigCli(program: Command) {
.option("--json", "Legacy alias for --strict-json", false)
.option(
"--dry-run",
"Validate changes without writing openclaw.json (checks run in builder/json/batch modes)",
"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 <alias>", "SecretRef builder: provider alias")

View File

@@ -14,7 +14,9 @@ export type ConfigSetDryRunResult = {
checks: {
schema: boolean;
resolvability: boolean;
resolvabilityComplete: boolean;
};
refsChecked: number;
skippedExecRefs: number;
errors?: ConfigSetDryRunError[];
};

View File

@@ -5,6 +5,7 @@ export type ConfigSetOptions = {
strictJson?: boolean;
json?: boolean;
dryRun?: boolean;
allowExec?: boolean;
refProvider?: string;
refSource?: string;
refId?: string;