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:
parent
9a455a8c08
commit
2d3bcbfe08
@ -176,19 +176,31 @@ openclaw config set channels.discord.token \
|
|||||||
--ref-id DISCORD_BOT_TOKEN \
|
--ref-id DISCORD_BOT_TOKEN \
|
||||||
--dry-run \
|
--dry-run \
|
||||||
--json
|
--json
|
||||||
|
|
||||||
|
openclaw config set channels.discord.token \
|
||||||
|
--ref-provider vault \
|
||||||
|
--ref-source exec \
|
||||||
|
--ref-id discord/token \
|
||||||
|
--dry-run \
|
||||||
|
--allow-exec
|
||||||
```
|
```
|
||||||
|
|
||||||
Dry-run behavior:
|
Dry-run behavior:
|
||||||
|
|
||||||
- Builder mode: requires full SecretRef resolvability for changed refs/providers.
|
- Builder mode: runs SecretRef resolvability checks for changed refs/providers.
|
||||||
- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation.
|
- 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:
|
`--dry-run --json` prints a machine-readable report:
|
||||||
|
|
||||||
- `ok`: whether dry-run passed
|
- `ok`: whether dry-run passed
|
||||||
- `operations`: number of assignments evaluated
|
- `operations`: number of assignments evaluated
|
||||||
- `checks`: whether schema/resolvability checks ran
|
- `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`
|
- `errors`: structured schema/resolvability failures when `ok=false`
|
||||||
|
|
||||||
### JSON Output Shape
|
### JSON Output Shape
|
||||||
@ -202,8 +214,10 @@ Dry-run behavior:
|
|||||||
checks: {
|
checks: {
|
||||||
schema: boolean,
|
schema: boolean,
|
||||||
resolvability: boolean,
|
resolvability: boolean,
|
||||||
|
resolvabilityComplete: boolean,
|
||||||
},
|
},
|
||||||
refsChecked: number,
|
refsChecked: number,
|
||||||
|
skippedExecRefs: number,
|
||||||
errors?: [
|
errors?: [
|
||||||
{
|
{
|
||||||
kind: "schema" | "resolvability",
|
kind: "schema" | "resolvability",
|
||||||
@ -224,9 +238,11 @@ Success example:
|
|||||||
"inputModes": ["builder"],
|
"inputModes": ["builder"],
|
||||||
"checks": {
|
"checks": {
|
||||||
"schema": false,
|
"schema": false,
|
||||||
"resolvability": true
|
"resolvability": true,
|
||||||
|
"resolvabilityComplete": true
|
||||||
},
|
},
|
||||||
"refsChecked": 1
|
"refsChecked": 1,
|
||||||
|
"skippedExecRefs": 0
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -240,9 +256,11 @@ Failure example:
|
|||||||
"inputModes": ["builder"],
|
"inputModes": ["builder"],
|
||||||
"checks": {
|
"checks": {
|
||||||
"schema": false,
|
"schema": false,
|
||||||
"resolvability": true
|
"resolvability": true,
|
||||||
|
"resolvabilityComplete": true
|
||||||
},
|
},
|
||||||
"refsChecked": 1,
|
"refsChecked": 1,
|
||||||
|
"skippedExecRefs": 0,
|
||||||
"errors": [
|
"errors": [
|
||||||
{
|
{
|
||||||
"kind": "resolvability",
|
"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.
|
- `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).
|
- `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.
|
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
|
||||||
|
|
||||||
## Subcommands
|
## Subcommands
|
||||||
|
|||||||
@ -400,8 +400,9 @@ Subcommands:
|
|||||||
- SecretRef builder mode: `config set <path> --ref-provider <provider> --ref-source <source> --ref-id <id>`
|
- 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> ...`
|
- 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>`
|
- 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`: validate assignments without writing `openclaw.json` (exec SecretRef checks are skipped by default).
|
||||||
- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors).
|
- `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 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 unset <path>`: remove a value.
|
||||||
- `config file`: print the active config file path.
|
- `config file`: print the active config file path.
|
||||||
|
|||||||
@ -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", () => {
|
describe("config cli integration", () => {
|
||||||
it("supports batch-file dry-run and then writes real config changes", async () => {
|
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-"));
|
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 });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -386,6 +386,7 @@ describe("config cli", () => {
|
|||||||
expect(helpText).toContain("--provider-source");
|
expect(helpText).toContain("--provider-source");
|
||||||
expect(helpText).toContain("--batch-json");
|
expect(helpText).toContain("--batch-json");
|
||||||
expect(helpText).toContain("--dry-run");
|
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 gateway.port 19001 --strict-json");
|
||||||
expect(helpText).toContain(
|
expect(helpText).toContain(
|
||||||
"openclaw config set channels.discord.token --ref-provider default --ref-source",
|
"openclaw config set channels.discord.token --ref-provider default --ref-source",
|
||||||
@ -556,6 +557,169 @@ describe("config cli", () => {
|
|||||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
|
||||||
const resolved: OpenClawConfig = {
|
const resolved: OpenClawConfig = {
|
||||||
gateway: { port: 18789 },
|
gateway: { port: 18789 },
|
||||||
@ -749,19 +913,66 @@ describe("config cli", () => {
|
|||||||
expect(typeof raw).toBe("string");
|
expect(typeof raw).toBe("string");
|
||||||
const payload = JSON.parse(String(raw)) as {
|
const payload = JSON.parse(String(raw)) as {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
checks: { schema: boolean; resolvability: boolean };
|
checks: { schema: boolean; resolvability: boolean; resolvabilityComplete: boolean };
|
||||||
refsChecked: number;
|
refsChecked: number;
|
||||||
|
skippedExecRefs: number;
|
||||||
operations: number;
|
operations: number;
|
||||||
};
|
};
|
||||||
expect(payload.ok).toBe(true);
|
expect(payload.ok).toBe(true);
|
||||||
expect(payload.operations).toBe(1);
|
expect(payload.operations).toBe(1);
|
||||||
expect(payload.refsChecked).toBe(1);
|
expect(payload.refsChecked).toBe(1);
|
||||||
|
expect(payload.skippedExecRefs).toBe(0);
|
||||||
expect(payload.checks).toEqual({
|
expect(payload.checks).toEqual({
|
||||||
schema: false,
|
schema: false,
|
||||||
resolvability: true,
|
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 () => {
|
it("emits structured JSON for --dry-run --json failure", async () => {
|
||||||
const resolved: OpenClawConfig = {
|
const resolved: OpenClawConfig = {
|
||||||
gateway: { port: 18789 },
|
gateway: { port: 18789 },
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import type { RuntimeEnv } from "../runtime.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import {
|
import {
|
||||||
formatExecSecretRefIdValidationMessage,
|
formatExecSecretRefIdValidationMessage,
|
||||||
|
isValidExecSecretRefId,
|
||||||
isValidFileSecretRefId,
|
isValidFileSecretRefId,
|
||||||
isValidSecretProviderAlias,
|
isValidSecretProviderAlias,
|
||||||
secretRefKey,
|
secretRefKey,
|
||||||
@ -815,6 +816,66 @@ async function collectDryRunResolvabilityErrors(params: {
|
|||||||
return failures;
|
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[] {
|
function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] {
|
||||||
const validated = validateConfigObjectRaw(config);
|
const validated = validateConfigObjectRaw(config);
|
||||||
if (validated.ok) {
|
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 schemaErrors = errors.filter((error) => error.kind === "schema");
|
||||||
const resolveErrors = errors.filter((error) => error.kind === "resolvability");
|
const resolveErrors = errors.filter((error) => error.kind === "resolvability");
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
@ -847,6 +912,11 @@ function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string {
|
|||||||
lines.push(`- ... ${resolveErrors.length - 5} more`);
|
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -868,6 +938,9 @@ export async function runConfigSet(opts: {
|
|||||||
if (!modeResolution.ok) {
|
if (!modeResolution.ok) {
|
||||||
throw modeError(modeResolution.error);
|
throw modeError(modeResolution.error);
|
||||||
}
|
}
|
||||||
|
if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) {
|
||||||
|
throw modeError("--allow-exec requires --dry-run.");
|
||||||
|
}
|
||||||
|
|
||||||
const batchEntries = parseBatchSource(opts.cliOptions);
|
const batchEntries = parseBatchSource(opts.cliOptions);
|
||||||
if (batchEntries) {
|
if (batchEntries) {
|
||||||
@ -903,14 +976,24 @@ export async function runConfigSet(opts: {
|
|||||||
operations,
|
operations,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
const selectedDryRunRefs = selectDryRunRefsForResolution({
|
||||||
|
refs,
|
||||||
|
allowExecInDryRun: Boolean(opts.cliOptions.allowExec),
|
||||||
|
});
|
||||||
const errors: ConfigSetDryRunError[] = [];
|
const errors: ConfigSetDryRunError[] = [];
|
||||||
if (hasJsonMode) {
|
if (hasJsonMode) {
|
||||||
errors.push(...collectDryRunSchemaErrors(nextConfig));
|
errors.push(...collectDryRunSchemaErrors(nextConfig));
|
||||||
}
|
}
|
||||||
if (hasJsonMode || hasBuilderMode) {
|
if (hasJsonMode || hasBuilderMode) {
|
||||||
|
errors.push(
|
||||||
|
...collectDryRunStaticErrorsForSkippedExecRefs({
|
||||||
|
refs: selectedDryRunRefs.skippedExecRefs,
|
||||||
|
config: nextConfig,
|
||||||
|
}),
|
||||||
|
);
|
||||||
errors.push(
|
errors.push(
|
||||||
...(await collectDryRunResolvabilityErrors({
|
...(await collectDryRunResolvabilityErrors({
|
||||||
refs,
|
refs: selectedDryRunRefs.refsToResolve,
|
||||||
config: nextConfig,
|
config: nextConfig,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@ -923,15 +1006,23 @@ export async function runConfigSet(opts: {
|
|||||||
checks: {
|
checks: {
|
||||||
schema: hasJsonMode,
|
schema: hasJsonMode,
|
||||||
resolvability: hasJsonMode || hasBuilderMode,
|
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 } : {}),
|
...(errors.length > 0 ? { errors } : {}),
|
||||||
};
|
};
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
if (opts.cliOptions.json) {
|
if (opts.cliOptions.json) {
|
||||||
throw new ConfigSetDryRunValidationError(dryRunResult);
|
throw new ConfigSetDryRunValidationError(dryRunResult);
|
||||||
}
|
}
|
||||||
throw new Error(formatDryRunFailureMessage(errors));
|
throw new Error(
|
||||||
|
formatDryRunFailureMessage({
|
||||||
|
errors,
|
||||||
|
skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (opts.cliOptions.json) {
|
if (opts.cliOptions.json) {
|
||||||
runtime.log(JSON.stringify(dryRunResult, null, 2));
|
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(
|
runtime.log(
|
||||||
info(
|
info(
|
||||||
`Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`,
|
`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("--json", "Legacy alias for --strict-json", false)
|
||||||
.option(
|
.option(
|
||||||
"--dry-run",
|
"--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,
|
false,
|
||||||
)
|
)
|
||||||
.option("--ref-provider <alias>", "SecretRef builder: provider alias")
|
.option("--ref-provider <alias>", "SecretRef builder: provider alias")
|
||||||
|
|||||||
@ -14,7 +14,9 @@ export type ConfigSetDryRunResult = {
|
|||||||
checks: {
|
checks: {
|
||||||
schema: boolean;
|
schema: boolean;
|
||||||
resolvability: boolean;
|
resolvability: boolean;
|
||||||
|
resolvabilityComplete: boolean;
|
||||||
};
|
};
|
||||||
refsChecked: number;
|
refsChecked: number;
|
||||||
|
skippedExecRefs: number;
|
||||||
errors?: ConfigSetDryRunError[];
|
errors?: ConfigSetDryRunError[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,6 +5,7 @@ export type ConfigSetOptions = {
|
|||||||
strictJson?: boolean;
|
strictJson?: boolean;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
|
allowExec?: boolean;
|
||||||
refProvider?: string;
|
refProvider?: string;
|
||||||
refSource?: string;
|
refSource?: string;
|
||||||
refId?: string;
|
refId?: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user