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:
Josh Avant
2026-03-17 18:15:49 -05:00
committed by GitHub
parent bd21442f7e
commit e99963100d
14 changed files with 2102 additions and 32 deletions

View File

@@ -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"));
});
});