CLI: expand config set with SecretRef/provider builders and dry-run (#49296)
* CLI: expand config set ref/provider builder and dry-run * Docs: revert README Discord token example
This commit is contained in:
parent
bd21442f7e
commit
e99963100d
@ -364,7 +364,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
|
||||
@ -96,8 +96,10 @@ You will need to create a new application with a bot, add the bot to your server
|
||||
Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent.
|
||||
|
||||
```bash
|
||||
openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json
|
||||
openclaw config set channels.discord.enabled true --json
|
||||
export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN"
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
|
||||
openclaw config set channels.discord.enabled true --strict-json
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
@ -121,7 +123,11 @@ openclaw gateway
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "YOUR_BOT_TOKEN",
|
||||
token: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -133,7 +139,7 @@ openclaw gateway
|
||||
DISCORD_BOT_TOKEN=...
|
||||
```
|
||||
|
||||
SecretRef values are also supported for `channels.discord.token` (env/file/exec providers). See [Secrets Management](/gateway/secrets).
|
||||
Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets).
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@ -7,9 +7,9 @@ title: "config"
|
||||
|
||||
# `openclaw config`
|
||||
|
||||
Config helpers: get/set/unset/validate values by path and print the active
|
||||
config file. Run without a subcommand to open
|
||||
the configure wizard (same as `openclaw configure`).
|
||||
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/validate
|
||||
values by path and print the active config file. Run without a subcommand to
|
||||
open the configure wizard (same as `openclaw configure`).
|
||||
|
||||
## Examples
|
||||
|
||||
@ -19,7 +19,10 @@ openclaw config get browser.executablePath
|
||||
openclaw config set browser.executablePath "/usr/bin/google-chrome"
|
||||
openclaw config set agents.defaults.heartbeat.every "2h"
|
||||
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
|
||||
openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json
|
||||
openclaw config unset tools.web.search.apiKey
|
||||
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
|
||||
openclaw config validate
|
||||
openclaw config validate --json
|
||||
```
|
||||
@ -51,6 +54,211 @@ openclaw config set gateway.port 19001 --strict-json
|
||||
openclaw config set channels.whatsapp.groups '["*"]' --strict-json
|
||||
```
|
||||
|
||||
## `config set` modes
|
||||
|
||||
`openclaw config set` supports four assignment styles:
|
||||
|
||||
1. Value mode: `openclaw config set <path> <value>`
|
||||
2. SecretRef builder mode:
|
||||
|
||||
```bash
|
||||
openclaw config set channels.discord.token \
|
||||
--ref-provider default \
|
||||
--ref-source env \
|
||||
--ref-id DISCORD_BOT_TOKEN
|
||||
```
|
||||
|
||||
3. Provider builder mode (`secrets.providers.<alias>` path only):
|
||||
|
||||
```bash
|
||||
openclaw config set secrets.providers.vault \
|
||||
--provider-source exec \
|
||||
--provider-command /usr/local/bin/openclaw-vault \
|
||||
--provider-arg read \
|
||||
--provider-arg openai/api-key \
|
||||
--provider-timeout-ms 5000
|
||||
```
|
||||
|
||||
4. Batch mode (`--batch-json` or `--batch-file`):
|
||||
|
||||
```bash
|
||||
openclaw config set --batch-json '[
|
||||
{
|
||||
"path": "secrets.providers.default",
|
||||
"provider": { "source": "env" }
|
||||
},
|
||||
{
|
||||
"path": "channels.discord.token",
|
||||
"ref": { "source": "env", "provider": "default", "id": "DISCORD_BOT_TOKEN" }
|
||||
}
|
||||
]'
|
||||
```
|
||||
|
||||
```bash
|
||||
openclaw config set --batch-file ./config-set.batch.json --dry-run
|
||||
```
|
||||
|
||||
Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth.
|
||||
`--strict-json` / `--json` do not change batch parsing behavior.
|
||||
|
||||
JSON path/value mode remains supported for both SecretRefs and providers:
|
||||
|
||||
```bash
|
||||
openclaw config set channels.discord.token \
|
||||
'{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}' \
|
||||
--strict-json
|
||||
|
||||
openclaw config set secrets.providers.vaultfile \
|
||||
'{"source":"file","path":"/etc/openclaw/secrets.json","mode":"json"}' \
|
||||
--strict-json
|
||||
```
|
||||
|
||||
## Provider Builder Flags
|
||||
|
||||
Provider builder targets must use `secrets.providers.<alias>` as the path.
|
||||
|
||||
Common flags:
|
||||
|
||||
- `--provider-source <env|file|exec>`
|
||||
- `--provider-timeout-ms <ms>` (`file`, `exec`)
|
||||
|
||||
Env provider (`--provider-source env`):
|
||||
|
||||
- `--provider-allowlist <ENV_VAR>` (repeatable)
|
||||
|
||||
File provider (`--provider-source file`):
|
||||
|
||||
- `--provider-path <path>` (required)
|
||||
- `--provider-mode <singleValue|json>`
|
||||
- `--provider-max-bytes <bytes>`
|
||||
|
||||
Exec provider (`--provider-source exec`):
|
||||
|
||||
- `--provider-command <path>` (required)
|
||||
- `--provider-arg <arg>` (repeatable)
|
||||
- `--provider-no-output-timeout-ms <ms>`
|
||||
- `--provider-max-output-bytes <bytes>`
|
||||
- `--provider-json-only`
|
||||
- `--provider-env <KEY=VALUE>` (repeatable)
|
||||
- `--provider-pass-env <ENV_VAR>` (repeatable)
|
||||
- `--provider-trusted-dir <path>` (repeatable)
|
||||
- `--provider-allow-insecure-path`
|
||||
- `--provider-allow-symlink-command`
|
||||
|
||||
Hardened exec provider example:
|
||||
|
||||
```bash
|
||||
openclaw config set secrets.providers.vault \
|
||||
--provider-source exec \
|
||||
--provider-command /usr/local/bin/openclaw-vault \
|
||||
--provider-arg read \
|
||||
--provider-arg openai/api-key \
|
||||
--provider-json-only \
|
||||
--provider-pass-env VAULT_TOKEN \
|
||||
--provider-trusted-dir /usr/local/bin \
|
||||
--provider-timeout-ms 5000
|
||||
```
|
||||
|
||||
## Dry run
|
||||
|
||||
Use `--dry-run` to validate changes without writing `openclaw.json`.
|
||||
|
||||
```bash
|
||||
openclaw config set channels.discord.token \
|
||||
--ref-provider default \
|
||||
--ref-source env \
|
||||
--ref-id DISCORD_BOT_TOKEN \
|
||||
--dry-run
|
||||
|
||||
openclaw config set channels.discord.token \
|
||||
--ref-provider default \
|
||||
--ref-source env \
|
||||
--ref-id DISCORD_BOT_TOKEN \
|
||||
--dry-run \
|
||||
--json
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`--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
|
||||
- `errors`: structured schema/resolvability failures when `ok=false`
|
||||
|
||||
### JSON Output Shape
|
||||
|
||||
```json5
|
||||
{
|
||||
ok: boolean,
|
||||
operations: number,
|
||||
configPath: string,
|
||||
inputModes: ["value" | "json" | "builder", ...],
|
||||
checks: {
|
||||
schema: boolean,
|
||||
resolvability: boolean,
|
||||
},
|
||||
refsChecked: number,
|
||||
errors?: [
|
||||
{
|
||||
kind: "schema" | "resolvability",
|
||||
message: string,
|
||||
ref?: string, // present for resolvability errors
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Success example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"operations": 1,
|
||||
"configPath": "~/.openclaw/openclaw.json",
|
||||
"inputModes": ["builder"],
|
||||
"checks": {
|
||||
"schema": false,
|
||||
"resolvability": true
|
||||
},
|
||||
"refsChecked": 1
|
||||
}
|
||||
```
|
||||
|
||||
Failure example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"operations": 1,
|
||||
"configPath": "~/.openclaw/openclaw.json",
|
||||
"inputModes": ["builder"],
|
||||
"checks": {
|
||||
"schema": false,
|
||||
"resolvability": true
|
||||
},
|
||||
"refsChecked": 1,
|
||||
"errors": [
|
||||
{
|
||||
"kind": "resolvability",
|
||||
"message": "Error: Environment variable \"MISSING_TEST_SECRET\" is not set.",
|
||||
"ref": "env:default:MISSING_TEST_SECRET"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
|
||||
|
||||
## Subcommands
|
||||
|
||||
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).
|
||||
|
||||
@ -101,6 +101,8 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
get
|
||||
set
|
||||
unset
|
||||
file
|
||||
validate
|
||||
completion
|
||||
doctor
|
||||
dashboard
|
||||
@ -393,7 +395,14 @@ subcommand launches the wizard.
|
||||
Subcommands:
|
||||
|
||||
- `config get <path>`: print a config value (dot/bracket path).
|
||||
- `config set <path> <value>`: set a value (JSON5 or raw string).
|
||||
- `config set`: supports four assignment modes:
|
||||
- value mode: `config set <path> <value>` (JSON5-or-string parsing)
|
||||
- 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 --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.
|
||||
- `config validate`: validate the current config against the schema without starting the gateway.
|
||||
|
||||
186
src/cli/config-cli.integration.test.ts
Normal file
186
src/cli/config-cli.integration.test.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { runConfigSet } from "./config-cli.js";
|
||||
|
||||
function createTestRuntime() {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
return {
|
||||
logs,
|
||||
errors,
|
||||
runtime: {
|
||||
log: (...args: unknown[]) => logs.push(args.map((arg) => String(arg)).join(" ")),
|
||||
error: (...args: unknown[]) => errors.push(args.map((arg) => String(arg)).join(" ")),
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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-"));
|
||||
const configPath = path.join(tempDir, "openclaw.json");
|
||||
const batchPath = path.join(tempDir, "batch.json");
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_TEST_FAST",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
]);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: { port: 18789 },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
batchPath,
|
||||
`${JSON.stringify(
|
||||
[
|
||||
{
|
||||
path: "secrets.providers.default",
|
||||
provider: { source: "env" },
|
||||
},
|
||||
{
|
||||
path: "channels.discord.token",
|
||||
ref: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
process.env.DISCORD_BOT_TOKEN = "test-token";
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
const runtime = createTestRuntime();
|
||||
const before = fs.readFileSync(configPath, "utf8");
|
||||
await runConfigSet({
|
||||
cliOptions: {
|
||||
batchFile: batchPath,
|
||||
dryRun: true,
|
||||
},
|
||||
runtime: runtime.runtime,
|
||||
});
|
||||
const afterDryRun = fs.readFileSync(configPath, "utf8");
|
||||
expect(afterDryRun).toBe(before);
|
||||
expect(runtime.errors).toEqual([]);
|
||||
expect(runtime.logs.some((line) => line.includes("Dry run successful: 2 update(s)"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await runConfigSet({
|
||||
cliOptions: {
|
||||
batchFile: batchPath,
|
||||
},
|
||||
runtime: runtime.runtime,
|
||||
});
|
||||
const afterWrite = JSON5.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(afterWrite.secrets?.providers?.default).toEqual({
|
||||
source: "env",
|
||||
});
|
||||
expect(afterWrite.channels?.discord?.token).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
});
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps file unchanged when real-file dry-run fails and reports JSON error payload", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-fail-"));
|
||||
const configPath = path.join(tempDir, "openclaw.json");
|
||||
const envSnapshot = captureEnv([
|
||||
"OPENCLAW_CONFIG_PATH",
|
||||
"OPENCLAW_TEST_FAST",
|
||||
"MISSING_TEST_SECRET",
|
||||
]);
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||
delete process.env.MISSING_TEST_SECRET;
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
const runtime = createTestRuntime();
|
||||
const before = fs.readFileSync(configPath, "utf8");
|
||||
await expect(
|
||||
runConfigSet({
|
||||
path: "channels.discord.token",
|
||||
cliOptions: {
|
||||
refProvider: "default",
|
||||
refSource: "env",
|
||||
refId: "MISSING_TEST_SECRET",
|
||||
dryRun: true,
|
||||
json: true,
|
||||
},
|
||||
runtime: runtime.runtime,
|
||||
}),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
const after = fs.readFileSync(configPath, "utf8");
|
||||
expect(after).toBe(before);
|
||||
expect(runtime.errors).toEqual([]);
|
||||
const raw = runtime.logs.at(-1);
|
||||
expect(raw).toBeTruthy();
|
||||
const payload = JSON.parse(raw ?? "{}") as {
|
||||
ok?: boolean;
|
||||
checks?: { schema?: boolean; resolvability?: boolean };
|
||||
errors?: Array<{ kind?: string; ref?: string }>;
|
||||
};
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.checks?.resolvability).toBe(true);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
|
||||
expect(payload.errors?.some((entry) => entry.ref?.includes("MISSING_TEST_SECRET"))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
clearConfigCache();
|
||||
clearRuntimeConfigSnapshot();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,3 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
|
||||
@ -12,6 +15,7 @@ const mockReadConfigFileSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
|
||||
const mockWriteConfigFile = vi.fn<
|
||||
(cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise<void>
|
||||
>(async () => {});
|
||||
const mockResolveSecretRefValue = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
|
||||
@ -19,6 +23,10 @@ vi.mock("../config/config.js", () => ({
|
||||
mockWriteConfigFile(cfg, options),
|
||||
}));
|
||||
|
||||
vi.mock("../secrets/resolve.js", () => ({
|
||||
resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args),
|
||||
}));
|
||||
|
||||
const mockLog = vi.fn();
|
||||
const mockError = vi.fn();
|
||||
const mockExit = vi.fn((code: number) => {
|
||||
@ -123,6 +131,7 @@ describe("config cli", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveSecretRefValue.mockResolvedValue("resolved-secret");
|
||||
});
|
||||
|
||||
describe("config set - issue #6070", () => {
|
||||
@ -345,6 +354,23 @@ describe("config cli", () => {
|
||||
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts --strict-json with batch mode and applies batch payload", async () => {
|
||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"gateway.auth.mode","value":"token"}]',
|
||||
"--strict-json",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.gateway?.auth).toEqual({ mode: "token" });
|
||||
});
|
||||
|
||||
it("shows --strict-json and keeps --json as a legacy alias in help", async () => {
|
||||
const program = new Command();
|
||||
registerConfigCli(program);
|
||||
@ -356,6 +382,485 @@ describe("config cli", () => {
|
||||
expect(helpText).toContain("--strict-json");
|
||||
expect(helpText).toContain("--json");
|
||||
expect(helpText).toContain("Legacy alias for --strict-json");
|
||||
expect(helpText).toContain("--ref-provider");
|
||||
expect(helpText).toContain("--provider-source");
|
||||
expect(helpText).toContain("--batch-json");
|
||||
expect(helpText).toContain("--dry-run");
|
||||
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",
|
||||
);
|
||||
expect(helpText).toContain("--ref-id DISCORD_BOT_TOKEN");
|
||||
expect(helpText).toContain(
|
||||
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config set builders and dry-run", () => {
|
||||
it("supports SecretRef builder mode without requiring a value argument", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.channels?.discord?.token).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports provider builder mode under secrets.providers.<alias>", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"secrets.providers.vaultfile",
|
||||
"--provider-source",
|
||||
"file",
|
||||
"--provider-path",
|
||||
"/tmp/vault.json",
|
||||
"--provider-mode",
|
||||
"json",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.secrets?.providers?.vaultfile).toEqual({
|
||||
source: "file",
|
||||
path: "/tmp/vault.json",
|
||||
mode: "json",
|
||||
});
|
||||
});
|
||||
|
||||
it("runs resolvability checks in builder dry-run mode without writing", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
|
||||
{
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
expect.objectContaining({
|
||||
env: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires schema validation in JSON dry-run mode", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"gateway.port",
|
||||
'"not-a-number"',
|
||||
"--strict-json",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: config schema validation failed."),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports batch mode for refs/providers in dry-run", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]',
|
||||
"--dry-run",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
} as never,
|
||||
} as never,
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.googlechat.serviceAccount",
|
||||
"--ref-provider",
|
||||
"vaultfile",
|
||||
"--ref-source",
|
||||
"file",
|
||||
"--ref-id",
|
||||
"/providers/googlechat/serviceAccount",
|
||||
]);
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.channels?.googlechat?.serviceAccountRef).toEqual({
|
||||
source: "file",
|
||||
provider: "vaultfile",
|
||||
id: "/providers/googlechat/serviceAccount",
|
||||
});
|
||||
expect(written.channels?.googlechat?.serviceAccount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects mixing ref-builder and provider-builder flags", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--provider-source",
|
||||
"env",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("config set mode error: choose exactly one mode"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mixing batch mode with builder flags", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
"[]",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"config set mode error: batch mode (--batch-json/--batch-file) cannot be combined",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("supports batch-file mode", async () => {
|
||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-batch-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
fs.writeFileSync(pathname, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
|
||||
try {
|
||||
await runConfigCommand(["config", "set", "--batch-file", pathname]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0];
|
||||
expect(written.gateway?.auth).toEqual({ mode: "token" });
|
||||
});
|
||||
|
||||
it("rejects malformed batch-file payloads", async () => {
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-config-batch-invalid-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
|
||||
);
|
||||
fs.writeFileSync(pathname, '{"path":"gateway.auth.mode","value":"token"}', "utf8");
|
||||
try {
|
||||
await expect(runConfigCommand(["config", "set", "--batch-file", pathname])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("--batch-file must be a JSON array."),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed batch entries with mixed operation keys", async () => {
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"channels.discord.token","value":"x","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("must include exactly one of: value, ref, provider"),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits structured JSON for --dry-run --json success", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--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: { schema: boolean; resolvability: boolean };
|
||||
refsChecked: number;
|
||||
operations: number;
|
||||
};
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.operations).toBe(1);
|
||||
expect(payload.refsChecked).toBe(1);
|
||||
expect(payload.checks).toEqual({
|
||||
schema: false,
|
||||
resolvability: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits structured JSON for --dry-run --json failure", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"channels.discord.token",
|
||||
"--ref-provider",
|
||||
"default",
|
||||
"--ref-source",
|
||||
"env",
|
||||
"--ref-id",
|
||||
"DISCORD_BOT_TOKEN",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const raw = mockLog.mock.calls.at(-1)?.[0];
|
||||
expect(typeof raw).toBe("string");
|
||||
const payload = JSON.parse(String(raw)) as {
|
||||
ok: boolean;
|
||||
errors?: Array<{ kind: string; message: string; ref?: string }>;
|
||||
};
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
|
||||
expect(
|
||||
payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("aggregates schema and resolvability failures in --dry-run --json mode", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"--batch-json",
|
||||
'[{"path":"gateway.port","value":"not-a-number"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const raw = mockLog.mock.calls.at(-1)?.[0];
|
||||
expect(typeof raw).toBe("string");
|
||||
const payload = JSON.parse(String(raw)) as {
|
||||
ok: boolean;
|
||||
errors?: Array<{ kind: string; message: string; ref?: string }>;
|
||||
};
|
||||
expect(payload.ok).toBe(false);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true);
|
||||
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
|
||||
expect(
|
||||
payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("fails dry-run when provider updates 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",
|
||||
"--provider-source",
|
||||
"env",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
|
||||
);
|
||||
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -1,26 +1,100 @@
|
||||
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,
|
||||
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 {
|
||||
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);
|
||||
@ -276,6 +350,628 @@ function ensureValidOllamaProviderForApiKeySet(
|
||||
});
|
||||
}
|
||||
|
||||
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<string, string> | undefined {
|
||||
if (!entries || entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
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.<alias>" (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(".") ?? "<provider>";
|
||||
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<string, unknown>;
|
||||
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(".") ?? "<provider>";
|
||||
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 <path>.");
|
||||
}
|
||||
if (params.value !== undefined) {
|
||||
throw modeError("ref builder mode does not accept <value>.");
|
||||
}
|
||||
if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) {
|
||||
throw modeError(
|
||||
"ref builder mode requires --ref-provider <alias>, --ref-source <env|file|exec>, and --ref-id <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 <path>.");
|
||||
}
|
||||
if (params.value !== undefined) {
|
||||
throw modeError("provider builder mode does not accept <value>.");
|
||||
}
|
||||
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 <path> when batch mode is not used.");
|
||||
}
|
||||
if (params.value === undefined) {
|
||||
throw modeError("value/json mode requires <value>.");
|
||||
}
|
||||
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<string, SecretRef>();
|
||||
const targetPaths = new Set<string>();
|
||||
const providerAliases = new Set<string>();
|
||||
|
||||
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<ConfigSetDryRunError[]> {
|
||||
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 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(errors: ConfigSetDryRunError[]): string {
|
||||
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 ?? "<unknown-ref>"} -> ${error.message}`),
|
||||
);
|
||||
if (resolveErrors.length > 5) {
|
||||
lines.push(`- ... ${resolveErrors.length - 5} more`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function runConfigSet(opts: {
|
||||
path?: string;
|
||||
value?: string;
|
||||
cliOptions: ConfigSetOptions;
|
||||
runtime?: RuntimeEnv;
|
||||
}) {
|
||||
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 modeResolution = resolveConfigSetMode({
|
||||
hasBatchMode,
|
||||
hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions),
|
||||
hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions),
|
||||
strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json),
|
||||
});
|
||||
if (!modeResolution.ok) {
|
||||
throw modeError(modeResolution.error);
|
||||
}
|
||||
|
||||
const batchEntries = parseBatchSource(opts.cliOptions);
|
||||
if (batchEntries) {
|
||||
if (opts.path !== undefined || opts.value !== undefined) {
|
||||
throw modeError("batch mode does not accept <path> or <value> 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<string, unknown>;
|
||||
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 errors: ConfigSetDryRunError[] = [];
|
||||
if (hasJsonMode) {
|
||||
errors.push(...collectDryRunSchemaErrors(nextConfig));
|
||||
}
|
||||
if (hasJsonMode || hasBuilderMode) {
|
||||
errors.push(
|
||||
...(await collectDryRunResolvabilityErrors({
|
||||
refs,
|
||||
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,
|
||||
},
|
||||
refsChecked: refs.length,
|
||||
...(errors.length > 0 ? { errors } : {}),
|
||||
};
|
||||
if (errors.length > 0) {
|
||||
if (opts.cliOptions.json) {
|
||||
throw new ConfigSetDryRunValidationError(dryRunResult);
|
||||
}
|
||||
throw new Error(formatDryRunFailureMessage(errors));
|
||||
}
|
||||
if (opts.cliOptions.json) {
|
||||
runtime.log(JSON.stringify(dryRunResult, null, 2));
|
||||
} else {
|
||||
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 {
|
||||
@ -425,30 +1121,72 @@ export function registerConfigCli(program: Command) {
|
||||
|
||||
cmd
|
||||
.command("set")
|
||||
.description("Set a config value by dot path")
|
||||
.argument("<path>", "Config path (dot or bracket notation)")
|
||||
.argument("<value>", "Value (JSON5 or raw string)")
|
||||
.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)
|
||||
.action(async (path: string, value: string, opts) => {
|
||||
try {
|
||||
const parsedPath = parseRequiredPath(path);
|
||||
const parsedValue = parseValue(value, {
|
||||
strictJson: Boolean(opts.strictJson || opts.json),
|
||||
});
|
||||
const snapshot = await loadValidConfig();
|
||||
// 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<string, unknown>;
|
||||
ensureValidOllamaProviderForApiKeySet(next, parsedPath);
|
||||
setAtPath(next, parsedPath, parsedValue);
|
||||
await writeConfigFile(next);
|
||||
defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
.option("--dry-run", "Validate changes without writing openclaw.json", false)
|
||||
.option("--ref-provider <alias>", "SecretRef builder: provider alias")
|
||||
.option("--ref-source <source>", "SecretRef builder: source (env|file|exec)")
|
||||
.option("--ref-id <id>", "SecretRef builder: ref id")
|
||||
.option("--provider-source <source>", "Provider builder: source (env|file|exec)")
|
||||
.option(
|
||||
"--provider-allowlist <envVar>",
|
||||
"Provider builder (env): allowlist entry (repeatable)",
|
||||
(value: string, previous: string[]) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option("--provider-path <path>", "Provider builder (file): path")
|
||||
.option("--provider-mode <mode>", "Provider builder (file): mode (singleValue|json)")
|
||||
.option("--provider-timeout-ms <ms>", "Provider builder (file|exec): timeout ms")
|
||||
.option("--provider-max-bytes <bytes>", "Provider builder (file): max bytes")
|
||||
.option("--provider-command <path>", "Provider builder (exec): absolute command path")
|
||||
.option(
|
||||
"--provider-arg <arg>",
|
||||
"Provider builder (exec): command arg (repeatable)",
|
||||
(value: string, previous: string[]) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option("--provider-no-output-timeout-ms <ms>", "Provider builder (exec): no-output timeout ms")
|
||||
.option("--provider-max-output-bytes <bytes>", "Provider builder (exec): max output bytes")
|
||||
.option("--provider-json-only", "Provider builder (exec): require JSON output", false)
|
||||
.option(
|
||||
"--provider-env <key=value>",
|
||||
"Provider builder (exec): env assignment (repeatable)",
|
||||
(value: string, previous: string[]) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--provider-pass-env <envVar>",
|
||||
"Provider builder (exec): pass host env var (repeatable)",
|
||||
(value: string, previous: string[]) => [...previous, value],
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--provider-trusted-dir <path>",
|
||||
"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 <json>", "Batch mode: JSON array of set operations")
|
||||
.option("--batch-file <path>", "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
|
||||
|
||||
20
src/cli/config-set-dryrun.ts
Normal file
20
src/cli/config-set-dryrun.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export type ConfigSetDryRunInputMode = "value" | "json" | "builder";
|
||||
|
||||
export type ConfigSetDryRunError = {
|
||||
kind: "schema" | "resolvability";
|
||||
message: string;
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
export type ConfigSetDryRunResult = {
|
||||
ok: boolean;
|
||||
operations: number;
|
||||
configPath: string;
|
||||
inputModes: ConfigSetDryRunInputMode[];
|
||||
checks: {
|
||||
schema: boolean;
|
||||
resolvability: boolean;
|
||||
};
|
||||
refsChecked: number;
|
||||
errors?: ConfigSetDryRunError[];
|
||||
};
|
||||
113
src/cli/config-set-input.test.ts
Normal file
113
src/cli/config-set-input.test.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseBatchSource } from "./config-set-input.js";
|
||||
|
||||
describe("config set input parsing", () => {
|
||||
it("returns null when no batch options are provided", () => {
|
||||
expect(parseBatchSource({})).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects using both --batch-json and --batch-file", () => {
|
||||
expect(() =>
|
||||
parseBatchSource({
|
||||
batchJson: "[]",
|
||||
batchFile: "/tmp/batch.json",
|
||||
}),
|
||||
).toThrow("Use either --batch-json or --batch-file, not both.");
|
||||
});
|
||||
|
||||
it("parses valid --batch-json payloads", () => {
|
||||
const parsed = parseBatchSource({
|
||||
batchJson:
|
||||
'[{"path":"gateway.auth.mode","value":"token"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]',
|
||||
});
|
||||
expect(parsed).toEqual([
|
||||
{
|
||||
path: "gateway.auth.mode",
|
||||
value: "token",
|
||||
},
|
||||
{
|
||||
path: "channels.discord.token",
|
||||
ref: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "DISCORD_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "secrets.providers.default",
|
||||
provider: {
|
||||
source: "env",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed --batch-json payloads", () => {
|
||||
expect(() =>
|
||||
parseBatchSource({
|
||||
batchJson: "{",
|
||||
}),
|
||||
).toThrow("Failed to parse --batch-json:");
|
||||
});
|
||||
|
||||
it("rejects --batch-json payloads that are not arrays", () => {
|
||||
expect(() =>
|
||||
parseBatchSource({
|
||||
batchJson: '{"path":"gateway.auth.mode","value":"token"}',
|
||||
}),
|
||||
).toThrow("--batch-json must be a JSON array.");
|
||||
});
|
||||
|
||||
it("rejects batch entries without path", () => {
|
||||
expect(() =>
|
||||
parseBatchSource({
|
||||
batchJson: '[{"value":"token"}]',
|
||||
}),
|
||||
).toThrow("--batch-json[0].path is required.");
|
||||
});
|
||||
|
||||
it("rejects batch entries that do not contain exactly one mode key", () => {
|
||||
expect(() =>
|
||||
parseBatchSource({
|
||||
batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]',
|
||||
}),
|
||||
).toThrow("--batch-json[0] must include exactly one of: value, ref, provider.");
|
||||
});
|
||||
|
||||
it("parses valid --batch-file payloads", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-"));
|
||||
const batchPath = path.join(tempDir, "batch.json");
|
||||
fs.writeFileSync(batchPath, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
|
||||
try {
|
||||
const parsed = parseBatchSource({
|
||||
batchFile: batchPath,
|
||||
});
|
||||
expect(parsed).toEqual([
|
||||
{
|
||||
path: "gateway.auth.mode",
|
||||
value: "token",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed --batch-file payloads", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-invalid-"));
|
||||
const batchPath = path.join(tempDir, "batch.json");
|
||||
fs.writeFileSync(batchPath, "{}", "utf8");
|
||||
try {
|
||||
expect(() =>
|
||||
parseBatchSource({
|
||||
batchFile: batchPath,
|
||||
}),
|
||||
).toThrow("--batch-file must be a JSON array.");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
130
src/cli/config-set-input.ts
Normal file
130
src/cli/config-set-input.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import fs from "node:fs";
|
||||
import JSON5 from "json5";
|
||||
|
||||
export type ConfigSetOptions = {
|
||||
strictJson?: boolean;
|
||||
json?: boolean;
|
||||
dryRun?: boolean;
|
||||
refProvider?: string;
|
||||
refSource?: string;
|
||||
refId?: string;
|
||||
providerSource?: string;
|
||||
providerAllowlist?: string[];
|
||||
providerPath?: string;
|
||||
providerMode?: string;
|
||||
providerTimeoutMs?: string;
|
||||
providerMaxBytes?: string;
|
||||
providerCommand?: string;
|
||||
providerArg?: string[];
|
||||
providerNoOutputTimeoutMs?: string;
|
||||
providerMaxOutputBytes?: string;
|
||||
providerJsonOnly?: boolean;
|
||||
providerEnv?: string[];
|
||||
providerPassEnv?: string[];
|
||||
providerTrustedDir?: string[];
|
||||
providerAllowInsecurePath?: boolean;
|
||||
providerAllowSymlinkCommand?: boolean;
|
||||
batchJson?: string;
|
||||
batchFile?: string;
|
||||
};
|
||||
|
||||
export type ConfigSetBatchEntry = {
|
||||
path: string;
|
||||
value?: unknown;
|
||||
ref?: unknown;
|
||||
provider?: unknown;
|
||||
};
|
||||
|
||||
export function hasBatchMode(opts: ConfigSetOptions): boolean {
|
||||
return Boolean(
|
||||
(opts.batchJson && opts.batchJson.trim().length > 0) ||
|
||||
(opts.batchFile && opts.batchFile.trim().length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRefBuilderOptions(opts: ConfigSetOptions): boolean {
|
||||
return Boolean(opts.refProvider || opts.refSource || opts.refId);
|
||||
}
|
||||
|
||||
export function hasProviderBuilderOptions(opts: ConfigSetOptions): boolean {
|
||||
return Boolean(
|
||||
opts.providerSource ||
|
||||
opts.providerAllowlist?.length ||
|
||||
opts.providerPath ||
|
||||
opts.providerMode ||
|
||||
opts.providerTimeoutMs ||
|
||||
opts.providerMaxBytes ||
|
||||
opts.providerCommand ||
|
||||
opts.providerArg?.length ||
|
||||
opts.providerNoOutputTimeoutMs ||
|
||||
opts.providerMaxOutputBytes ||
|
||||
opts.providerJsonOnly ||
|
||||
opts.providerEnv?.length ||
|
||||
opts.providerPassEnv?.length ||
|
||||
opts.providerTrustedDir?.length ||
|
||||
opts.providerAllowInsecurePath ||
|
||||
opts.providerAllowSymlinkCommand,
|
||||
);
|
||||
}
|
||||
|
||||
function parseJson5Raw(raw: string, label: string): unknown {
|
||||
try {
|
||||
return JSON5.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse ${label}: ${String(err)}`, { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntry[] {
|
||||
const parsed = parseJson5Raw(raw, sourceLabel);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${sourceLabel} must be a JSON array.`);
|
||||
}
|
||||
const out: ConfigSetBatchEntry[] = [];
|
||||
for (const [index, entry] of parsed.entries()) {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`${sourceLabel}[${index}] must be an object.`);
|
||||
}
|
||||
const typed = entry as Record<string, unknown>;
|
||||
const path = typeof typed.path === "string" ? typed.path.trim() : "";
|
||||
if (!path) {
|
||||
throw new Error(`${sourceLabel}[${index}].path is required.`);
|
||||
}
|
||||
const hasValue = Object.prototype.hasOwnProperty.call(typed, "value");
|
||||
const hasRef = Object.prototype.hasOwnProperty.call(typed, "ref");
|
||||
const hasProvider = Object.prototype.hasOwnProperty.call(typed, "provider");
|
||||
const modeCount = Number(hasValue) + Number(hasRef) + Number(hasProvider);
|
||||
if (modeCount !== 1) {
|
||||
throw new Error(
|
||||
`${sourceLabel}[${index}] must include exactly one of: value, ref, provider.`,
|
||||
);
|
||||
}
|
||||
out.push({
|
||||
path,
|
||||
...(hasValue ? { value: typed.value } : {}),
|
||||
...(hasRef ? { ref: typed.ref } : {}),
|
||||
...(hasProvider ? { provider: typed.provider } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[] | null {
|
||||
const hasInline = Boolean(opts.batchJson && opts.batchJson.trim().length > 0);
|
||||
const hasFile = Boolean(opts.batchFile && opts.batchFile.trim().length > 0);
|
||||
if (!hasInline && !hasFile) {
|
||||
return null;
|
||||
}
|
||||
if (hasInline && hasFile) {
|
||||
throw new Error("Use either --batch-json or --batch-file, not both.");
|
||||
}
|
||||
if (hasInline) {
|
||||
return parseBatchEntries(opts.batchJson as string, "--batch-json");
|
||||
}
|
||||
const pathname = (opts.batchFile as string).trim();
|
||||
if (!pathname) {
|
||||
throw new Error("--batch-file must not be empty.");
|
||||
}
|
||||
const raw = fs.readFileSync(pathname, "utf8");
|
||||
return parseBatchEntries(raw, "--batch-file");
|
||||
}
|
||||
80
src/cli/config-set-mode.test.ts
Normal file
80
src/cli/config-set-mode.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveConfigSetMode } from "./config-set-parser.js";
|
||||
|
||||
describe("resolveConfigSetMode", () => {
|
||||
it("selects value mode by default", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: false,
|
||||
hasRefBuilderOptions: false,
|
||||
hasProviderBuilderOptions: false,
|
||||
strictJson: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, mode: "value" });
|
||||
});
|
||||
|
||||
it("selects json mode when strict parsing is enabled", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: false,
|
||||
hasRefBuilderOptions: false,
|
||||
hasProviderBuilderOptions: false,
|
||||
strictJson: true,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, mode: "json" });
|
||||
});
|
||||
|
||||
it("selects ref-builder mode when ref flags are present", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: false,
|
||||
hasRefBuilderOptions: true,
|
||||
hasProviderBuilderOptions: false,
|
||||
strictJson: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, mode: "ref_builder" });
|
||||
});
|
||||
|
||||
it("selects provider-builder mode when provider flags are present", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: false,
|
||||
hasRefBuilderOptions: false,
|
||||
hasProviderBuilderOptions: true,
|
||||
strictJson: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, mode: "provider_builder" });
|
||||
});
|
||||
|
||||
it("returns batch mode when batch flags are present", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: true,
|
||||
hasRefBuilderOptions: false,
|
||||
hasProviderBuilderOptions: false,
|
||||
strictJson: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, mode: "batch" });
|
||||
});
|
||||
|
||||
it("rejects ref-builder and provider-builder collisions", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: false,
|
||||
hasRefBuilderOptions: true,
|
||||
hasProviderBuilderOptions: true,
|
||||
strictJson: false,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result).toMatchObject({
|
||||
error: expect.stringContaining("choose exactly one mode"),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects mixing batch mode with builder flags", () => {
|
||||
const result = resolveConfigSetMode({
|
||||
hasBatchMode: true,
|
||||
hasRefBuilderOptions: true,
|
||||
hasProviderBuilderOptions: false,
|
||||
strictJson: false,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result).toMatchObject({
|
||||
error: expect.stringContaining("batch mode (--batch-json/--batch-file) cannot be combined"),
|
||||
});
|
||||
});
|
||||
});
|
||||
43
src/cli/config-set-parser.ts
Normal file
43
src/cli/config-set-parser.ts
Normal file
@ -0,0 +1,43 @@
|
||||
export type ConfigSetMode = "value" | "json" | "ref_builder" | "provider_builder" | "batch";
|
||||
|
||||
export type ConfigSetModeResolution =
|
||||
| {
|
||||
ok: true;
|
||||
mode: ConfigSetMode;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function resolveConfigSetMode(params: {
|
||||
hasBatchMode: boolean;
|
||||
hasRefBuilderOptions: boolean;
|
||||
hasProviderBuilderOptions: boolean;
|
||||
strictJson: boolean;
|
||||
}): ConfigSetModeResolution {
|
||||
if (params.hasBatchMode) {
|
||||
if (params.hasRefBuilderOptions || params.hasProviderBuilderOptions) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"batch mode (--batch-json/--batch-file) cannot be combined with ref builder (--ref-*) or provider builder (--provider-*) flags.",
|
||||
};
|
||||
}
|
||||
return { ok: true, mode: "batch" };
|
||||
}
|
||||
if (params.hasRefBuilderOptions && params.hasProviderBuilderOptions) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"choose exactly one mode: ref builder (--ref-provider/--ref-source/--ref-id) or provider builder (--provider-*), not both.",
|
||||
};
|
||||
}
|
||||
if (params.hasRefBuilderOptions) {
|
||||
return { ok: true, mode: "ref_builder" };
|
||||
}
|
||||
if (params.hasProviderBuilderOptions) {
|
||||
return { ok: true, mode: "provider_builder" };
|
||||
}
|
||||
return { ok: true, mode: params.strictJson ? "json" : "value" };
|
||||
}
|
||||
@ -239,6 +239,24 @@ export function resolvePlanTargetAgainstRegistry(candidate: {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null {
|
||||
for (const entry of OPENCLAW_COMPILED_SECRET_TARGETS) {
|
||||
if (!entry.includeInPlan) {
|
||||
continue;
|
||||
}
|
||||
const matched = matchPathTokens(pathSegments, entry.pathTokens);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures);
|
||||
if (!resolved) {
|
||||
continue;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function discoverConfigSecretTargets(
|
||||
config: OpenClawConfig,
|
||||
): DiscoveredConfigSecretTarget[] {
|
||||
|
||||
@ -3,7 +3,10 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { buildSecretRefCredentialMatrix } from "./credential-matrix.js";
|
||||
import { discoverConfigSecretTargetsByIds } from "./target-registry.js";
|
||||
import {
|
||||
discoverConfigSecretTargetsByIds,
|
||||
resolveConfigSecretTargetByPath,
|
||||
} from "./target-registry.js";
|
||||
|
||||
describe("secret target registry", () => {
|
||||
it("stays in sync with docs/reference/secretref-user-supplied-credentials-matrix.json", () => {
|
||||
@ -96,4 +99,15 @@ describe("secret target registry", () => {
|
||||
expect(targets[0]?.entry.id).toBe("talk.apiKey");
|
||||
expect(targets[0]?.path).toBe("talk.apiKey");
|
||||
});
|
||||
|
||||
it("resolves config targets by exact path including sibling ref metadata", () => {
|
||||
const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]);
|
||||
expect(target).not.toBeNull();
|
||||
expect(target?.entry.id).toBe("channels.googlechat.serviceAccount");
|
||||
expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]);
|
||||
});
|
||||
|
||||
it("returns null when no config target path matches", () => {
|
||||
expect(resolveConfigSecretTargetByPath(["gateway", "auth", "mode"])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user