From e99963100d22c870633ad890d766a98ab611a36f Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Tue, 17 Mar 2026 18:15:49 -0500 Subject: [PATCH] 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 --- README.md | 2 +- docs/channels/discord.md | 14 +- docs/cli/config.md | 214 ++++++- docs/cli/index.md | 11 +- src/cli/config-cli.integration.test.ts | 186 ++++++ src/cli/config-cli.test.ts | 505 ++++++++++++++++ src/cli/config-cli.ts | 782 ++++++++++++++++++++++++- src/cli/config-set-dryrun.ts | 20 + src/cli/config-set-input.test.ts | 113 ++++ src/cli/config-set-input.ts | 130 ++++ src/cli/config-set-mode.test.ts | 80 +++ src/cli/config-set-parser.ts | 43 ++ src/secrets/target-registry-query.ts | 18 + src/secrets/target-registry.test.ts | 16 +- 14 files changed, 2102 insertions(+), 32 deletions(-) create mode 100644 src/cli/config-cli.integration.test.ts create mode 100644 src/cli/config-set-dryrun.ts create mode 100644 src/cli/config-set-input.test.ts create mode 100644 src/cli/config-set-input.ts create mode 100644 src/cli/config-set-mode.test.ts create mode 100644 src/cli/config-set-parser.ts diff --git a/README.md b/README.md index 418e2a070af..1c836da84ee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 2b2266c4c83..0f7b6ac7074 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -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). diff --git a/docs/cli/config.md b/docs/cli/config.md index fa0d62e8511..ba4e6adf60f 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -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 ` +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.` 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.` as the path. + +Common flags: + +- `--provider-source ` +- `--provider-timeout-ms ` (`file`, `exec`) + +Env provider (`--provider-source env`): + +- `--provider-allowlist ` (repeatable) + +File provider (`--provider-source file`): + +- `--provider-path ` (required) +- `--provider-mode ` +- `--provider-max-bytes ` + +Exec provider (`--provider-source exec`): + +- `--provider-command ` (required) +- `--provider-arg ` (repeatable) +- `--provider-no-output-timeout-ms ` +- `--provider-max-output-bytes ` +- `--provider-json-only` +- `--provider-env ` (repeatable) +- `--provider-pass-env ` (repeatable) +- `--provider-trusted-dir ` (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). diff --git a/docs/cli/index.md b/docs/cli/index.md index 4b4197cde6f..5acbb4b3166 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -101,6 +101,8 @@ openclaw [--dev] [--profile ] get set unset + file + validate completion doctor dashboard @@ -393,7 +395,14 @@ subcommand launches the wizard. Subcommands: - `config get `: print a config value (dot/bracket path). -- `config set `: set a value (JSON5 or raw string). +- `config set`: supports four assignment modes: + - value mode: `config set ` (JSON5-or-string parsing) + - SecretRef builder mode: `config set --ref-provider --ref-source --ref-id ` + - provider builder mode: `config set secrets.providers. --provider-source ...` + - batch mode: `config set --batch-json ''` or `config set --batch-file ` +- `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 `: remove a value. - `config file`: print the active config file path. - `config validate`: validate the current config against the schema without starting the gateway. diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts new file mode 100644 index 00000000000..1224d56c220 --- /dev/null +++ b/src/cli/config-cli.integration.test.ts @@ -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 }); + } + }); +}); diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index 8ee785df189..69ba866534e 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -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>(); const mockWriteConfigFile = vi.fn< (cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise >(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.", 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")); }); }); diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 5167658040a..f7efaf1c865 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -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 | undefined { + if (!entries || entries.length === 0) { + return undefined; + } + const env: Record = {}; + for (const entry of entries) { + const separator = entry.indexOf("="); + if (separator <= 0) { + throw new Error(`--provider-env expects KEY=VALUE entries (received: "${entry}").`); + } + const key = entry.slice(0, separator).trim(); + if (!key) { + throw new Error(`--provider-env key must not be empty (received: "${entry}").`); + } + env[key] = entry.slice(separator + 1); + } + return Object.keys(env).length > 0 ? env : undefined; +} + +function parseProviderAliasPath(path: PathSegment[]): string { + const expectedPrefixMatches = + path.length === 3 && + path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && + path[1] === SECRET_PROVIDER_PATH_PREFIX[1]; + if (!expectedPrefixMatches) { + throw new Error( + 'Provider builder mode requires path "secrets.providers." (example: secrets.providers.vault).', + ); + } + const alias = path[2] ?? ""; + if (!isValidSecretProviderAlias(alias)) { + throw new Error( + `Provider alias "${alias}" must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`, + ); + } + return alias; +} + +function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig { + const sourceRaw = opts.providerSource?.trim(); + if (!sourceRaw) { + throw new Error("--provider-source is required in provider builder mode."); + } + const source = parseSecretRefSource(sourceRaw, "--provider-source"); + const timeoutMs = parseOptionalPositiveInteger(opts.providerTimeoutMs, "--provider-timeout-ms"); + const maxBytes = parseOptionalPositiveInteger(opts.providerMaxBytes, "--provider-max-bytes"); + const noOutputTimeoutMs = parseOptionalPositiveInteger( + opts.providerNoOutputTimeoutMs, + "--provider-no-output-timeout-ms", + ); + const maxOutputBytes = parseOptionalPositiveInteger( + opts.providerMaxOutputBytes, + "--provider-max-output-bytes", + ); + const providerEnv = parseProviderEnvEntries(opts.providerEnv); + + let provider: SecretProviderConfig; + if (source === "env") { + const allowlist = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean); + for (const envName of allowlist) { + if (!isValidEnvSecretRefId(envName)) { + throw new Error( + `--provider-allowlist entry "${envName}" must match /^[A-Z][A-Z0-9_]{0,127}$/.`, + ); + } + } + provider = { + source: "env", + ...(allowlist.length > 0 ? { allowlist } : {}), + }; + } else if (source === "file") { + const filePath = opts.providerPath?.trim(); + if (!filePath) { + throw new Error("--provider-path is required when --provider-source file is used."); + } + const modeRaw = opts.providerMode?.trim(); + if (modeRaw && modeRaw !== "singleValue" && modeRaw !== "json") { + throw new Error("--provider-mode must be one of: singleValue, json."); + } + const mode = modeRaw === "singleValue" || modeRaw === "json" ? modeRaw : undefined; + provider = { + source: "file", + path: filePath, + ...(mode ? { mode } : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(maxBytes !== undefined ? { maxBytes } : {}), + }; + } else { + const command = opts.providerCommand?.trim(); + if (!command) { + throw new Error("--provider-command is required when --provider-source exec is used."); + } + provider = { + source: "exec", + command, + ...(opts.providerArg && opts.providerArg.length > 0 + ? { args: opts.providerArg.map((entry) => entry.trim()) } + : {}), + ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}), + ...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}), + ...(opts.providerJsonOnly ? { jsonOnly: true } : {}), + ...(providerEnv ? { env: providerEnv } : {}), + ...(opts.providerPassEnv && opts.providerPassEnv.length > 0 + ? { passEnv: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) } + : {}), + ...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0 + ? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) } + : {}), + ...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}), + ...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}), + }; + } + + const validated = SecretProviderSchema.safeParse(provider); + if (!validated.success) { + const issue = validated.error.issues[0]; + const issuePath = issue?.path?.join(".") ?? ""; + const issueMessage = issue?.message ?? "Invalid provider config."; + throw new Error(`Provider builder config invalid at ${issuePath}: ${issueMessage}`); + } + return validated.data; +} + +function parseSecretRefFromUnknown(value: unknown, label: string): SecretRef { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object with source/provider/id.`); + } + const candidate = value as Record; + if ( + typeof candidate.provider !== "string" || + typeof candidate.source !== "string" || + typeof candidate.id !== "string" + ) { + throw new Error(`${label} must include string fields: source, provider, id.`); + } + return parseSecretRefBuilder({ + provider: candidate.provider, + source: candidate.source, + id: candidate.id, + fieldPrefix: label, + }); +} + +function buildRefAssignmentOperation(params: { + requestedPath: PathSegment[]; + ref: SecretRef; + inputMode: ConfigSetInputMode; +}): ConfigSetOperation { + const resolved = resolveConfigSecretTargetByPath(params.requestedPath); + if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) { + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: resolved.refPathSegments, + value: params.ref, + touchedSecretTargetPath: toDotPath(resolved.pathSegments), + assignedRef: params.ref, + ...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}), + }; + } + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: params.requestedPath, + value: params.ref, + touchedSecretTargetPath: resolved + ? toDotPath(resolved.pathSegments) + : toDotPath(params.requestedPath), + assignedRef: params.ref, + ...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}), + }; +} + +function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null { + if ( + path.length === 3 && + path[0] === SECRET_PROVIDER_PATH_PREFIX[0] && + path[1] === SECRET_PROVIDER_PATH_PREFIX[1] + ) { + return path[2] ?? null; + } + return null; +} + +function buildValueAssignmentOperation(params: { + requestedPath: PathSegment[]; + value: unknown; + inputMode: ConfigSetInputMode; +}): ConfigSetOperation { + const resolved = resolveConfigSecretTargetByPath(params.requestedPath); + const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath); + const coercedRef = coerceSecretRef(params.value); + return { + inputMode: params.inputMode, + requestedPath: params.requestedPath, + setPath: params.requestedPath, + value: params.value, + ...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}), + ...(providerAlias ? { touchedProviderAlias: providerAlias } : {}), + ...(coercedRef ? { assignedRef: coercedRef } : {}), + }; +} + +function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] { + const operations: ConfigSetOperation[] = []; + for (const [index, entry] of entries.entries()) { + const path = parseRequiredPath(entry.path); + if (entry.ref !== undefined) { + const ref = parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`); + operations.push( + buildRefAssignmentOperation({ + requestedPath: path, + ref, + inputMode: "json", + }), + ); + continue; + } + if (entry.provider !== undefined) { + const alias = parseProviderAliasPath(path); + const validated = SecretProviderSchema.safeParse(entry.provider); + if (!validated.success) { + const issue = validated.error.issues[0]; + const issuePath = issue?.path?.join(".") ?? ""; + throw new Error( + `batch[${index}].provider invalid at ${issuePath}: ${issue?.message ?? ""}`, + ); + } + operations.push({ + inputMode: "json", + requestedPath: path, + setPath: path, + value: validated.data, + touchedProviderAlias: alias, + }); + continue; + } + operations.push( + buildValueAssignmentOperation({ + requestedPath: path, + value: entry.value, + inputMode: "json", + }), + ); + } + return operations; +} + +function modeError(message: string): Error { + return new Error(`config set mode error: ${message}`); +} + +function buildSingleSetOperations(params: { + path?: string; + value?: string; + opts: ConfigSetOptions; +}): ConfigSetOperation[] { + const pathProvided = typeof params.path === "string" && params.path.trim().length > 0; + const parsedPath = pathProvided ? parseRequiredPath(params.path as string) : null; + const strictJson = Boolean(params.opts.strictJson || params.opts.json); + const modeResolution = resolveConfigSetMode({ + hasBatchMode: false, + hasRefBuilderOptions: hasRefBuilderOptions(params.opts), + hasProviderBuilderOptions: hasProviderBuilderOptions(params.opts), + strictJson, + }); + if (!modeResolution.ok) { + throw modeError(modeResolution.error); + } + + if (modeResolution.mode === "ref_builder") { + if (!pathProvided || !parsedPath) { + throw modeError("ref builder mode requires ."); + } + if (params.value !== undefined) { + throw modeError("ref builder mode does not accept ."); + } + if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) { + throw modeError( + "ref builder mode requires --ref-provider , --ref-source , and --ref-id .", + ); + } + const ref = parseSecretRefBuilder({ + provider: params.opts.refProvider, + source: params.opts.refSource, + id: params.opts.refId, + fieldPrefix: "ref", + }); + return [ + buildRefAssignmentOperation({ + requestedPath: parsedPath, + ref, + inputMode: "builder", + }), + ]; + } + + if (modeResolution.mode === "provider_builder") { + if (!pathProvided || !parsedPath) { + throw modeError("provider builder mode requires ."); + } + if (params.value !== undefined) { + throw modeError("provider builder mode does not accept ."); + } + const alias = parseProviderAliasPath(parsedPath); + const provider = buildProviderFromBuilder(params.opts); + return [ + { + inputMode: "builder", + requestedPath: parsedPath, + setPath: parsedPath, + value: provider, + touchedProviderAlias: alias, + }, + ]; + } + + if (!pathProvided || !parsedPath) { + throw modeError("value/json mode requires when batch mode is not used."); + } + if (params.value === undefined) { + throw modeError("value/json mode requires ."); + } + const parsedValue = parseValue(params.value, { strictJson }); + return [ + buildValueAssignmentOperation({ + requestedPath: parsedPath, + value: parsedValue, + inputMode: modeResolution.mode === "json" ? "json" : "value", + }), + ]; +} + +function collectDryRunRefs(params: { + config: OpenClawConfig; + operations: ConfigSetOperation[]; +}): SecretRef[] { + const refsByKey = new Map(); + const targetPaths = new Set(); + const providerAliases = new Set(); + + for (const operation of params.operations) { + if (operation.assignedRef) { + refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef); + } + if (operation.touchedSecretTargetPath) { + targetPaths.add(operation.touchedSecretTargetPath); + } + if (operation.touchedProviderAlias) { + providerAliases.add(operation.touchedProviderAlias); + } + } + + if (targetPaths.size === 0 && providerAliases.size === 0) { + return [...refsByKey.values()]; + } + + const defaults = params.config.secrets?.defaults; + for (const target of discoverConfigSecretTargets(params.config)) { + const { ref } = resolveSecretInputRef({ + value: target.value, + refValue: target.refValue, + defaults, + }); + if (!ref) { + continue; + } + if (targetPaths.has(target.path) || providerAliases.has(ref.provider)) { + refsByKey.set(secretRefKey(ref), ref); + } + } + return [...refsByKey.values()]; +} + +async function collectDryRunResolvabilityErrors(params: { + refs: SecretRef[]; + config: OpenClawConfig; +}): Promise { + const failures: ConfigSetDryRunError[] = []; + for (const ref of params.refs) { + try { + await resolveSecretRefValue(ref, { + config: params.config, + env: process.env, + }); + } catch (err) { + failures.push({ + kind: "resolvability", + message: String(err), + ref: `${ref.source}:${ref.provider}:${ref.id}`, + }); + } + } + return failures; +} + +function 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 ?? ""} -> ${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 or arguments."); + } + } + const operations = batchEntries + ? parseBatchOperations(batchEntries) + : buildSingleSetOperations({ + path: opts.path, + value: opts.value, + opts: opts.cliOptions, + }); + const snapshot = await loadValidConfig(runtime); + // Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults) + // instead of snapshot.config (runtime-merged with defaults). + // This prevents runtime defaults from leaking into the written config file (issue #6070) + const next = structuredClone(snapshot.resolved) as Record; + for (const operation of operations) { + ensureValidOllamaProviderForApiKeySet(next, operation.setPath); + setAtPath(next, operation.setPath, operation.value); + } + const nextConfig = next as OpenClawConfig; + + if (opts.cliOptions.dryRun) { + const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); + const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); + const refs = + hasJsonMode || hasBuilderMode + ? collectDryRunRefs({ + config: nextConfig, + operations, + }) + : []; + const 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("", "Config path (dot or bracket notation)") - .argument("", "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; - 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 ", "SecretRef builder: provider alias") + .option("--ref-source ", "SecretRef builder: source (env|file|exec)") + .option("--ref-id ", "SecretRef builder: ref id") + .option("--provider-source ", "Provider builder: source (env|file|exec)") + .option( + "--provider-allowlist ", + "Provider builder (env): allowlist entry (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option("--provider-path ", "Provider builder (file): path") + .option("--provider-mode ", "Provider builder (file): mode (singleValue|json)") + .option("--provider-timeout-ms ", "Provider builder (file|exec): timeout ms") + .option("--provider-max-bytes ", "Provider builder (file): max bytes") + .option("--provider-command ", "Provider builder (exec): absolute command path") + .option( + "--provider-arg ", + "Provider builder (exec): command arg (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option("--provider-no-output-timeout-ms ", "Provider builder (exec): no-output timeout ms") + .option("--provider-max-output-bytes ", "Provider builder (exec): max output bytes") + .option("--provider-json-only", "Provider builder (exec): require JSON output", false) + .option( + "--provider-env ", + "Provider builder (exec): env assignment (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-pass-env ", + "Provider builder (exec): pass host env var (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-trusted-dir ", + "Provider builder (exec): trusted directory (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .option( + "--provider-allow-insecure-path", + "Provider builder (exec): bypass strict path permission checks", + false, + ) + .option( + "--provider-allow-symlink-command", + "Provider builder (exec): allow command symlink path", + false, + ) + .option("--batch-json ", "Batch mode: JSON array of set operations") + .option("--batch-file ", "Batch mode: read JSON array of set operations from file") + .action(async (path: string | undefined, value: string | undefined, opts: ConfigSetOptions) => { + await runConfigSet({ + path, + value, + cliOptions: opts, + }); }); cmd diff --git a/src/cli/config-set-dryrun.ts b/src/cli/config-set-dryrun.ts new file mode 100644 index 00000000000..c122a47b33f --- /dev/null +++ b/src/cli/config-set-dryrun.ts @@ -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[]; +}; diff --git a/src/cli/config-set-input.test.ts b/src/cli/config-set-input.test.ts new file mode 100644 index 00000000000..fd13aaea46b --- /dev/null +++ b/src/cli/config-set-input.test.ts @@ -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 }); + } + }); +}); diff --git a/src/cli/config-set-input.ts b/src/cli/config-set-input.ts new file mode 100644 index 00000000000..b5de984fcdd --- /dev/null +++ b/src/cli/config-set-input.ts @@ -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; + 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"); +} diff --git a/src/cli/config-set-mode.test.ts b/src/cli/config-set-mode.test.ts new file mode 100644 index 00000000000..062f8f2e9aa --- /dev/null +++ b/src/cli/config-set-mode.test.ts @@ -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"), + }); + }); +}); diff --git a/src/cli/config-set-parser.ts b/src/cli/config-set-parser.ts new file mode 100644 index 00000000000..a3cac0217bc --- /dev/null +++ b/src/cli/config-set-parser.ts @@ -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" }; +} diff --git a/src/secrets/target-registry-query.ts b/src/secrets/target-registry-query.ts index fcfdc694f85..230b68f0180 100644 --- a/src/secrets/target-registry-query.ts +++ b/src/secrets/target-registry-query.ts @@ -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[] { diff --git a/src/secrets/target-registry.test.ts b/src/secrets/target-registry.test.ts index cc536fd2eb3..78e9e5f1cfe 100644 --- a/src/secrets/target-registry.test.ts +++ b/src/secrets/target-registry.test.ts @@ -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(); + }); });