mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 04:36:04 +00:00
CLI: dedupe config validate errors and expose allowed values
This commit is contained in:
@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
|
||||
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
|
||||
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
|
||||
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
|
||||
|
||||
64
src/agents/context.lookup.test.ts
Normal file
64
src/agents/context.lookup.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("lookupContextTokens", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("returns configured model context window on first lookup", async () => {
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: () => ({
|
||||
models: {
|
||||
providers: {
|
||||
openrouter: {
|
||||
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.doMock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.doMock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
|
||||
}));
|
||||
vi.doMock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({})),
|
||||
discoverModels: vi.fn(() => ({
|
||||
getAll: () => [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const { lookupContextTokens } = await import("./context.js");
|
||||
expect(lookupContextTokens("openrouter/claude-sonnet")).toBe(321_000);
|
||||
});
|
||||
|
||||
it("does not skip eager warmup when --profile is followed by -- terminator", async () => {
|
||||
const loadConfigMock = vi.fn(() => ({ models: {} }));
|
||||
vi.doMock("../config/config.js", () => ({
|
||||
loadConfig: loadConfigMock,
|
||||
}));
|
||||
vi.doMock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.doMock("./agent-paths.js", () => ({
|
||||
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
|
||||
}));
|
||||
vi.doMock("./pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({})),
|
||||
discoverModels: vi.fn(() => ({
|
||||
getAll: () => [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const argvSnapshot = process.argv;
|
||||
process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"];
|
||||
try {
|
||||
await import("./context.js");
|
||||
expect(loadConfigMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
process.argv = argvSnapshot;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
|
||||
import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
|
||||
@@ -66,55 +67,114 @@ export function applyConfiguredContextWindows(params: {
|
||||
}
|
||||
|
||||
const MODEL_CACHE = new Map<string, number>();
|
||||
const loadPromise = (async () => {
|
||||
let cfg: ReturnType<typeof loadConfig> | undefined;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
let configuredWindowsPrimed = false;
|
||||
|
||||
function getCommandPathFromArgv(argv: string[]): string[] {
|
||||
const args = argv.slice(2);
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (!arg || arg === FLAG_TERMINATOR) {
|
||||
break;
|
||||
}
|
||||
const consumed = consumeRootOptionToken(args, i);
|
||||
if (consumed > 0) {
|
||||
i += consumed - 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
tokens.push(arg);
|
||||
if (tokens.length >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): boolean {
|
||||
const [primary, secondary] = getCommandPathFromArgv(argv);
|
||||
return primary === "config" && secondary === "validate";
|
||||
}
|
||||
|
||||
function primeConfiguredContextWindows(): OpenClawConfig | undefined {
|
||||
if (configuredWindowsPrimed) {
|
||||
return undefined;
|
||||
}
|
||||
configuredWindowsPrimed = true;
|
||||
try {
|
||||
cfg = loadConfig();
|
||||
const cfg = loadConfig();
|
||||
applyConfiguredContextWindows({
|
||||
cache: MODEL_CACHE,
|
||||
modelsConfig: cfg.models as ModelsConfig | undefined,
|
||||
});
|
||||
return cfg;
|
||||
} catch {
|
||||
// If config can't be loaded, leave cache empty.
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
} catch {
|
||||
// Continue with best-effort discovery/overrides.
|
||||
function ensureContextWindowCacheLoaded(): Promise<void> {
|
||||
const cfg = primeConfiguredContextWindows();
|
||||
if (loadPromise) {
|
||||
return loadPromise;
|
||||
}
|
||||
loadPromise = (async () => {
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js");
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;
|
||||
const models =
|
||||
typeof modelRegistry.getAvailable === "function"
|
||||
? modelRegistry.getAvailable()
|
||||
: modelRegistry.getAll();
|
||||
applyDiscoveredContextWindows({
|
||||
try {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
} catch {
|
||||
// Continue with best-effort discovery/overrides.
|
||||
}
|
||||
|
||||
try {
|
||||
const { discoverAuthStorage, discoverModels } = await import("./pi-model-discovery.js");
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const authStorage = discoverAuthStorage(agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, agentDir) as unknown as ModelRegistryLike;
|
||||
const models =
|
||||
typeof modelRegistry.getAvailable === "function"
|
||||
? modelRegistry.getAvailable()
|
||||
: modelRegistry.getAll();
|
||||
applyDiscoveredContextWindows({
|
||||
cache: MODEL_CACHE,
|
||||
models,
|
||||
});
|
||||
} catch {
|
||||
// If model discovery fails, continue with config overrides only.
|
||||
}
|
||||
|
||||
applyConfiguredContextWindows({
|
||||
cache: MODEL_CACHE,
|
||||
models,
|
||||
modelsConfig: cfg.models as ModelsConfig | undefined,
|
||||
});
|
||||
} catch {
|
||||
// If model discovery fails, continue with config overrides only.
|
||||
}
|
||||
|
||||
applyConfiguredContextWindows({
|
||||
cache: MODEL_CACHE,
|
||||
modelsConfig: cfg.models as ModelsConfig | undefined,
|
||||
})().catch(() => {
|
||||
// Keep lookup best-effort.
|
||||
});
|
||||
})().catch(() => {
|
||||
// Keep lookup best-effort.
|
||||
});
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
export function lookupContextTokens(modelId?: string): number | undefined {
|
||||
if (!modelId) {
|
||||
return undefined;
|
||||
}
|
||||
// Best-effort: kick off loading, but don't block.
|
||||
void loadPromise;
|
||||
void ensureContextWindowCacheLoaded();
|
||||
return MODEL_CACHE.get(modelId);
|
||||
}
|
||||
|
||||
if (!shouldSkipEagerContextWindowWarmup()) {
|
||||
// Keep prior behavior where model limits begin loading during startup.
|
||||
// This avoids a cold-start miss on the first context token lookup.
|
||||
void ensureContextWindowCacheLoaded();
|
||||
}
|
||||
|
||||
function resolveConfiguredModelParams(
|
||||
cfg: OpenClawConfig | undefined,
|
||||
provider: string,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildParseArgv,
|
||||
getFlagValue,
|
||||
getCommandPath,
|
||||
getCommandPathWithRootOptions,
|
||||
getPrimaryCommand,
|
||||
getPositiveIntFlagValue,
|
||||
getVerboseFlag,
|
||||
@@ -160,6 +161,15 @@ describe("argv helpers", () => {
|
||||
expect(getCommandPath(argv, 2)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("extracts command path while skipping known root option values", () => {
|
||||
expect(
|
||||
getCommandPathWithRootOptions(
|
||||
["node", "openclaw", "--profile", "work", "--no-color", "config", "validate"],
|
||||
2,
|
||||
),
|
||||
).toEqual(["config", "validate"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "returns first command token",
|
||||
@@ -171,6 +181,11 @@ describe("argv helpers", () => {
|
||||
argv: ["node", "openclaw"],
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
name: "skips known root option values",
|
||||
argv: ["node", "openclaw", "--log-level", "debug", "status"],
|
||||
expected: "status",
|
||||
},
|
||||
])("returns primary command: $name", ({ argv, expected }) => {
|
||||
expect(getPrimaryCommand(argv)).toBe(expected);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { isBunRuntime, isNodeRuntime } from "../daemon/runtime-binary.js";
|
||||
import {
|
||||
consumeRootOptionToken,
|
||||
FLAG_TERMINATOR,
|
||||
isValueToken,
|
||||
} from "../infra/cli-root-options.js";
|
||||
|
||||
const HELP_FLAGS = new Set(["-h", "--help"]);
|
||||
const VERSION_FLAGS = new Set(["-V", "--version"]);
|
||||
const ROOT_VERSION_ALIAS_FLAG = "-v";
|
||||
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
|
||||
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);
|
||||
const FLAG_TERMINATOR = "--";
|
||||
|
||||
export function hasHelpOrVersion(argv: string[]): boolean {
|
||||
return (
|
||||
@@ -13,19 +15,6 @@ export function hasHelpOrVersion(argv: string[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isValueToken(arg: string | undefined): boolean {
|
||||
if (!arg) {
|
||||
return false;
|
||||
}
|
||||
if (arg === FLAG_TERMINATOR) {
|
||||
return false;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
return true;
|
||||
}
|
||||
return /^-\d+(?:\.\d+)?$/.test(arg);
|
||||
}
|
||||
|
||||
function parsePositiveInt(value: string): number | undefined {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed <= 0) {
|
||||
@@ -62,17 +51,9 @@ export function hasRootVersionAlias(argv: string[]): boolean {
|
||||
hasAlias = true;
|
||||
continue;
|
||||
}
|
||||
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--profile=")) {
|
||||
continue;
|
||||
}
|
||||
if (ROOT_VALUE_FLAGS.has(arg)) {
|
||||
const next = args[i + 1];
|
||||
if (isValueToken(next)) {
|
||||
i += 1;
|
||||
}
|
||||
const consumed = consumeRootOptionToken(args, i);
|
||||
if (consumed > 0) {
|
||||
i += consumed - 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
@@ -109,17 +90,9 @@ function isRootInvocationForFlags(
|
||||
hasTarget = true;
|
||||
continue;
|
||||
}
|
||||
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
|
||||
continue;
|
||||
}
|
||||
if (ROOT_VALUE_FLAGS.has(arg)) {
|
||||
const next = args[i + 1];
|
||||
if (isValueToken(next)) {
|
||||
i += 1;
|
||||
}
|
||||
const consumed = consumeRootOptionToken(args, i);
|
||||
if (consumed > 0) {
|
||||
i += consumed - 1;
|
||||
continue;
|
||||
}
|
||||
// Unknown flags and subcommand-scoped help/version should fall back to Commander.
|
||||
@@ -170,6 +143,18 @@ export function getPositiveIntFlagValue(argv: string[], name: string): number |
|
||||
}
|
||||
|
||||
export function getCommandPath(argv: string[], depth = 2): string[] {
|
||||
return getCommandPathInternal(argv, depth, { skipRootOptions: false });
|
||||
}
|
||||
|
||||
export function getCommandPathWithRootOptions(argv: string[], depth = 2): string[] {
|
||||
return getCommandPathInternal(argv, depth, { skipRootOptions: true });
|
||||
}
|
||||
|
||||
function getCommandPathInternal(
|
||||
argv: string[],
|
||||
depth: number,
|
||||
opts: { skipRootOptions: boolean },
|
||||
): string[] {
|
||||
const args = argv.slice(2);
|
||||
const path: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
@@ -180,6 +165,13 @@ export function getCommandPath(argv: string[], depth = 2): string[] {
|
||||
if (arg === "--") {
|
||||
break;
|
||||
}
|
||||
if (opts.skipRootOptions) {
|
||||
const consumed = consumeRootOptionToken(args, i);
|
||||
if (consumed > 0) {
|
||||
i += consumed - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
@@ -192,7 +184,7 @@ export function getCommandPath(argv: string[], depth = 2): string[] {
|
||||
}
|
||||
|
||||
export function getPrimaryCommand(argv: string[]): string | null {
|
||||
const [primary] = getCommandPath(argv, 1);
|
||||
const [primary] = getCommandPathWithRootOptions(argv, 1);
|
||||
return primary ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,55 @@ describe("config cli", () => {
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves allowed-values metadata in --json output", async () => {
|
||||
setSnapshotOnce({
|
||||
path: "/tmp/custom-openclaw.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: {},
|
||||
resolved: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
issues: [
|
||||
{
|
||||
path: "update.channel",
|
||||
message: 'Invalid input (allowed: "stable", "beta", "dev")',
|
||||
allowedValues: ["stable", "beta", "dev"],
|
||||
allowedValuesHiddenCount: 0,
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
legacyIssues: [],
|
||||
});
|
||||
|
||||
await expect(runConfigCommand(["config", "validate", "--json"])).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
const raw = mockLog.mock.calls.at(0)?.[0];
|
||||
expect(typeof raw).toBe("string");
|
||||
const payload = JSON.parse(String(raw)) as {
|
||||
valid: boolean;
|
||||
path: string;
|
||||
issues: Array<{
|
||||
path: string;
|
||||
message: string;
|
||||
allowedValues?: string[];
|
||||
allowedValuesHiddenCount?: number;
|
||||
}>;
|
||||
};
|
||||
expect(payload.valid).toBe(false);
|
||||
expect(payload.path).toBe("/tmp/custom-openclaw.json");
|
||||
expect(payload.issues).toEqual([
|
||||
{
|
||||
path: "update.channel",
|
||||
message: 'Invalid input (allowed: "stable", "beta", "dev")',
|
||||
allowedValues: ["stable", "beta", "dev"],
|
||||
},
|
||||
]);
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints file-not-found and exits 1 when config file is missing", async () => {
|
||||
setSnapshotOnce({
|
||||
path: "/tmp/openclaw.json",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import JSON5 from "json5";
|
||||
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";
|
||||
@@ -16,10 +17,6 @@ type PathSegment = string;
|
||||
type ConfigSetParseOpts = {
|
||||
strictJson?: boolean;
|
||||
};
|
||||
type ConfigIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
|
||||
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
|
||||
@@ -102,17 +99,6 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function normalizeConfigIssues(issues: ReadonlyArray<ConfigIssue>): ConfigIssue[] {
|
||||
return issues.map((issue) => ({
|
||||
path: issue.path || "<root>",
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatConfigIssueLines(issues: ReadonlyArray<ConfigIssue>, marker: string): string[] {
|
||||
return normalizeConfigIssues(issues).map((issue) => `${marker} ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
|
||||
function formatDoctorHint(message: string): string {
|
||||
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
|
||||
}
|
||||
@@ -249,7 +235,7 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
|
||||
return snapshot;
|
||||
}
|
||||
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
||||
for (const line of formatConfigIssueLines(snapshot.issues, "-")) {
|
||||
for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) {
|
||||
runtime.error(line);
|
||||
}
|
||||
runtime.error(formatDoctorHint("to repair, then retry."));
|
||||
@@ -381,7 +367,7 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim
|
||||
runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2));
|
||||
} else {
|
||||
runtime.error(danger(`Config invalid at ${shortPath}:`));
|
||||
for (const line of formatConfigIssueLines(issues, danger("×"))) {
|
||||
for (const line of formatConfigIssueLines(issues, danger("×"), { normalizeRoot: true })) {
|
||||
runtime.error(` ${line}`);
|
||||
}
|
||||
runtime.error("");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveControlUiLinks } from "../../commands/onboard-helpers.js";
|
||||
import { formatConfigIssueLine } from "../../config/issue-format.js";
|
||||
import {
|
||||
resolveGatewayLaunchAgentLabel,
|
||||
resolveGatewaySystemdServiceName,
|
||||
@@ -110,7 +111,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
if (!status.config.cli.valid && status.config.cli.issues?.length) {
|
||||
for (const issue of status.config.cli.issues.slice(0, 5)) {
|
||||
defaultRuntime.error(
|
||||
`${errorText("Config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
|
||||
`${errorText("Config issue:")} ${formatConfigIssueLine(issue, "", { normalizeRoot: true })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -120,7 +121,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
||||
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
|
||||
for (const issue of status.config.daemon.issues.slice(0, 5)) {
|
||||
defaultRuntime.error(
|
||||
`${errorText("Service config issue:")} ${issue.path || "<root>"}: ${issue.message}`,
|
||||
`${errorText("Service config issue:")} ${formatConfigIssueLine(issue, "", { normalizeRoot: true })}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-flow.js";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { formatConfigIssueLines } from "../../config/issue-format.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
@@ -28,10 +29,6 @@ function resetConfigGuardStateForTests() {
|
||||
configSnapshotPromise = null;
|
||||
}
|
||||
|
||||
function formatConfigIssues(issues: Array<{ path: string; message: string }>): string[] {
|
||||
return issues.map((issue) => `- ${issue.path || "<root>"}: ${issue.message}`);
|
||||
}
|
||||
|
||||
async function getConfigSnapshot() {
|
||||
// Tests often mutate config fixtures; caching can make those flaky.
|
||||
if (process.env.VITEST === "true") {
|
||||
@@ -83,11 +80,12 @@ export async function ensureConfigReady(params: {
|
||||
subcommandName &&
|
||||
ALLOWED_INVALID_GATEWAY_SUBCOMMANDS.has(subcommandName))
|
||||
: false;
|
||||
const issues = snapshot.exists && !snapshot.valid ? formatConfigIssues(snapshot.issues) : [];
|
||||
const legacyIssues =
|
||||
snapshot.legacyIssues.length > 0
|
||||
? snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`)
|
||||
const issues =
|
||||
snapshot.exists && !snapshot.valid
|
||||
? formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })
|
||||
: [];
|
||||
const legacyIssues =
|
||||
snapshot.legacyIssues.length > 0 ? formatConfigIssueLines(snapshot.legacyIssues, "-") : [];
|
||||
|
||||
const invalid = snapshot.exists && !snapshot.valid;
|
||||
if (!invalid) {
|
||||
|
||||
@@ -103,6 +103,10 @@ describe("registerPreActionHooks", () => {
|
||||
.argument("<value>")
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
config
|
||||
.command("validate")
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
registerPreActionHooks(program, "9.9.9-test");
|
||||
return program;
|
||||
}
|
||||
@@ -204,6 +208,24 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses config guard for config validate", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["config", "validate"],
|
||||
processArgv: ["node", "openclaw", "config", "validate"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bypasses config guard for config validate when root option values are present", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["config", "validate"],
|
||||
processArgv: ["node", "openclaw", "--profile", "work", "config", "validate"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
program = buildProgram();
|
||||
const hooks = (
|
||||
|
||||
@@ -3,7 +3,12 @@ import { setVerbose } from "../../globals.js";
|
||||
import { isTruthyEnvValue } from "../../infra/env.js";
|
||||
import type { LogLevel } from "../../logging/levels.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { getCommandPath, getVerboseFlag, hasFlag, hasHelpOrVersion } from "../argv.js";
|
||||
import {
|
||||
getCommandPathWithRootOptions,
|
||||
getVerboseFlag,
|
||||
hasFlag,
|
||||
hasHelpOrVersion,
|
||||
} from "../argv.js";
|
||||
import { emitCliBanner } from "../banner.js";
|
||||
import { resolveCliName } from "../cli-name.js";
|
||||
|
||||
@@ -34,6 +39,22 @@ const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]);
|
||||
let configGuardModulePromise: Promise<typeof import("./config-guard.js")> | undefined;
|
||||
let pluginRegistryModulePromise: Promise<typeof import("../plugin-registry.js")> | undefined;
|
||||
|
||||
function shouldBypassConfigGuard(commandPath: string[]): boolean {
|
||||
const [primary, secondary] = commandPath;
|
||||
if (!primary) {
|
||||
return false;
|
||||
}
|
||||
if (CONFIG_GUARD_BYPASS_COMMANDS.has(primary)) {
|
||||
return true;
|
||||
}
|
||||
// config validate is the explicit validation command; let it render
|
||||
// validation failures directly without preflight guard output duplication.
|
||||
if (primary === "config" && secondary === "validate") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function loadConfigGuardModule() {
|
||||
configGuardModulePromise ??= import("./config-guard.js");
|
||||
return configGuardModulePromise;
|
||||
@@ -82,7 +103,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
if (hasHelpOrVersion(argv)) {
|
||||
return;
|
||||
}
|
||||
const commandPath = getCommandPath(argv, 2);
|
||||
const commandPath = getCommandPathWithRootOptions(argv, 2);
|
||||
const hideBanner =
|
||||
isTruthyEnvValue(process.env.OPENCLAW_HIDE_BANNER) ||
|
||||
commandPath[0] === "update" ||
|
||||
@@ -100,7 +121,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
if (!verbose) {
|
||||
process.env.NODE_NO_WARNINGS ??= "1";
|
||||
}
|
||||
if (CONFIG_GUARD_BYPASS_COMMANDS.has(commandPath[0])) {
|
||||
if (shouldBypassConfigGuard(commandPath)) {
|
||||
return;
|
||||
}
|
||||
const suppressDoctorStdout = isJsonOutputMode(commandPath, argv);
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findRoutedCommand } from "./routes.js";
|
||||
|
||||
const runConfigGetMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const runConfigUnsetMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const modelsListCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const modelsStatusCommandMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("../config-cli.js", () => ({
|
||||
runConfigGet: runConfigGetMock,
|
||||
runConfigUnset: runConfigUnsetMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/models.js", () => ({
|
||||
modelsListCommand: modelsListCommandMock,
|
||||
modelsStatusCommand: modelsStatusCommandMock,
|
||||
}));
|
||||
|
||||
describe("program routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function expectRoute(path: string[]) {
|
||||
const route = findRoutedCommand(path);
|
||||
expect(route).not.toBeNull();
|
||||
@@ -58,6 +77,31 @@ describe("program routes", () => {
|
||||
await expectRunFalse(["config", "unset"], ["node", "openclaw", "config", "unset"]);
|
||||
});
|
||||
|
||||
it("passes config get path correctly when root option values precede command", async () => {
|
||||
const route = expectRoute(["config", "get"]);
|
||||
await expect(
|
||||
route?.run([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--log-level",
|
||||
"debug",
|
||||
"config",
|
||||
"get",
|
||||
"update.channel",
|
||||
"--json",
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
expect(runConfigGetMock).toHaveBeenCalledWith({ path: "update.channel", json: true });
|
||||
});
|
||||
|
||||
it("passes config unset path correctly when root option values precede command", async () => {
|
||||
const route = expectRoute(["config", "unset"]);
|
||||
await expect(
|
||||
route?.run(["node", "openclaw", "--profile", "work", "config", "unset", "update.channel"]),
|
||||
).resolves.toBe(true);
|
||||
expect(runConfigUnsetMock).toHaveBeenCalledWith({ path: "update.channel" });
|
||||
});
|
||||
|
||||
it("returns false for memory status route when --agent value is missing", async () => {
|
||||
await expectRunFalse(["memory", "status"], ["node", "openclaw", "memory", "status", "--agent"]);
|
||||
});
|
||||
@@ -95,4 +139,39 @@ describe("program routes", () => {
|
||||
["node", "openclaw", "models", "status", "--probe-profile"],
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts negative-number probe profile values", async () => {
|
||||
const route = expectRoute(["models", "status"]);
|
||||
await expect(
|
||||
route?.run([
|
||||
"node",
|
||||
"openclaw",
|
||||
"models",
|
||||
"status",
|
||||
"--probe-provider",
|
||||
"openai",
|
||||
"--probe-timeout",
|
||||
"5000",
|
||||
"--probe-concurrency",
|
||||
"2",
|
||||
"--probe-max-tokens",
|
||||
"64",
|
||||
"--probe-profile",
|
||||
"-1",
|
||||
"--agent",
|
||||
"default",
|
||||
]),
|
||||
).resolves.toBe(true);
|
||||
expect(modelsStatusCommandMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
probeProvider: "openai",
|
||||
probeTimeout: "5000",
|
||||
probeConcurrency: "2",
|
||||
probeMaxTokens: "64",
|
||||
probeProfile: "-1",
|
||||
agent: "default",
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consumeRootOptionToken, isValueToken } from "../../infra/cli-root-options.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
|
||||
|
||||
@@ -102,13 +103,23 @@ const routeMemoryStatus: RouteSpec = {
|
||||
function getCommandPositionals(argv: string[]): string[] {
|
||||
const out: string[] = [];
|
||||
const args = argv.slice(2);
|
||||
for (const arg of args) {
|
||||
let commandStarted = false;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (!arg || arg === "--") {
|
||||
break;
|
||||
}
|
||||
if (!commandStarted) {
|
||||
const consumed = consumeRootOptionToken(args, i);
|
||||
if (consumed > 0) {
|
||||
i += consumed - 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (arg.startsWith("-")) {
|
||||
continue;
|
||||
}
|
||||
commandStarted = true;
|
||||
out.push(arg);
|
||||
}
|
||||
return out;
|
||||
@@ -124,7 +135,7 @@ function getFlagValues(argv: string[], name: string): string[] | null {
|
||||
}
|
||||
if (arg === name) {
|
||||
const next = args[i + 1];
|
||||
if (!next || next === "--" || next.startsWith("-")) {
|
||||
if (!isValueToken(next)) {
|
||||
return null;
|
||||
}
|
||||
values.push(next);
|
||||
|
||||
@@ -69,4 +69,16 @@ describe("tryRouteCli", () => {
|
||||
commandPath: ["status"],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes status when root options precede the command", async () => {
|
||||
await expect(tryRouteCli(["node", "openclaw", "--log-level", "debug", "status"])).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(findRoutedCommandMock).toHaveBeenCalledWith(["status"]);
|
||||
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
|
||||
runtime: expect.any(Object),
|
||||
commandPath: ["status"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isTruthyEnvValue } from "../infra/env.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { getCommandPath, hasFlag, hasHelpOrVersion } from "./argv.js";
|
||||
import { getCommandPathWithRootOptions, hasFlag, hasHelpOrVersion } from "./argv.js";
|
||||
import { emitCliBanner } from "./banner.js";
|
||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
||||
import { ensureConfigReady } from "./program/config-guard.js";
|
||||
@@ -34,7 +34,7 @@ export async function tryRouteCli(argv: string[]): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = getCommandPath(argv, 2);
|
||||
const path = getCommandPathWithRootOptions(argv, 2);
|
||||
if (!path[0]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ describe("shouldEnsureCliPath", () => {
|
||||
|
||||
it("skips path bootstrap for read-only fast paths", () => {
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "status"])).toBe(false);
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "--log-level", "debug", "status"])).toBe(false);
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "sessions", "--json"])).toBe(false);
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "config", "get", "update"])).toBe(false);
|
||||
expect(shouldEnsureCliPath(["node", "openclaw", "models", "status", "--json"])).toBe(false);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
import { getCommandPath, getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
|
||||
import { getCommandPathWithRootOptions, getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
|
||||
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
import { normalizeWindowsArgv } from "./windows-argv.js";
|
||||
@@ -46,7 +46,7 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
|
||||
if (hasHelpOrVersion(argv)) {
|
||||
return false;
|
||||
}
|
||||
const [primary, secondary] = getCommandPath(argv, 2);
|
||||
const [primary, secondary] = getCommandPathWithRootOptions(argv, 2);
|
||||
if (!primary) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveGatewayPort,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { formatConfigIssueLines } from "../../config/issue-format.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
@@ -655,7 +656,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (opts.channel && !configSnapshot.valid) {
|
||||
const issues = configSnapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`);
|
||||
const issues = formatConfigIssueLines(configSnapshot.issues, "-");
|
||||
defaultRuntime.error(["Config is invalid; cannot set update channel.", ...issues].join("\n"));
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { type OpenClawConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
|
||||
export async function requireValidConfigSnapshot(
|
||||
@@ -9,7 +10,7 @@ export async function requireValidConfigSnapshot(
|
||||
if (snapshot.exists && !snapshot.valid) {
|
||||
const issues =
|
||||
snapshot.issues.length > 0
|
||||
? snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")
|
||||
? formatConfigIssueLines(snapshot.issues, "-").join("\n")
|
||||
: "Unknown validation issue.";
|
||||
runtime.error(`Config invalid:\n${issues}`);
|
||||
runtime.error(`Fix the config or run ${formatCliCommand("openclaw doctor")}.`);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
|
||||
import { OpenClawSchema } from "../config/zod-schema.js";
|
||||
@@ -1753,13 +1754,13 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}
|
||||
const warnings = snapshot.warnings ?? [];
|
||||
if (warnings.length > 0) {
|
||||
const lines = warnings.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
|
||||
const lines = formatConfigIssueLines(warnings, "-").join("\n");
|
||||
note(lines, "Config warnings");
|
||||
}
|
||||
|
||||
if (snapshot.legacyIssues.length > 0) {
|
||||
note(
|
||||
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
|
||||
formatConfigIssueLines(snapshot.legacyIssues, "-").join("\n"),
|
||||
"Compatibility config keys detected",
|
||||
);
|
||||
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { formatConfigIssueLines } from "../../config/issue-format.js";
|
||||
import { toAgentModelListLike } from "../../config/model-input.js";
|
||||
import type { AgentModelConfig } from "../../config/types.agents-shared.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
@@ -64,7 +65,7 @@ export const isLocalBaseUrl = (baseUrl: string) => {
|
||||
export async function loadValidConfigOrThrow(): Promise<OpenClawConfig> {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!snapshot.valid) {
|
||||
const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n");
|
||||
const issues = formatConfigIssueLines(snapshot.issues, "-").join("\n");
|
||||
throw new Error(`Invalid config at ${snapshot.path}\n${issues}`);
|
||||
}
|
||||
return snapshot.config;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProgressReporter } from "../../cli/progress.js";
|
||||
import { formatConfigIssueLine } from "../../config/issue-format.js";
|
||||
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
|
||||
import { formatPortDiagnostics } from "../../infra/ports.js";
|
||||
import {
|
||||
@@ -88,7 +89,7 @@ export async function appendStatusAllDiagnosis(params: {
|
||||
issues.findIndex((x) => x.path === issue.path && x.message === issue.message) === index,
|
||||
);
|
||||
for (const issue of uniqueIssues.slice(0, 12)) {
|
||||
lines.push(` - ${issue.path}: ${issue.message}`);
|
||||
lines.push(` ${formatConfigIssueLine(issue, "-")}`);
|
||||
}
|
||||
if (uniqueIssues.length > 12) {
|
||||
lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`);
|
||||
|
||||
27
src/config/allowed-values.test.ts
Normal file
27
src/config/allowed-values.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { summarizeAllowedValues } from "./allowed-values.js";
|
||||
|
||||
describe("summarizeAllowedValues", () => {
|
||||
it("does not collapse mixed-type entries that stringify similarly", () => {
|
||||
const summary = summarizeAllowedValues([1, "1", 1, "1"]);
|
||||
expect(summary).not.toBeNull();
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
expect(summary.hiddenCount).toBe(0);
|
||||
expect(summary.formatted).toContain('1, "1"');
|
||||
expect(summary.values).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps distinct long values even when labels truncate the same way", () => {
|
||||
const prefix = "a".repeat(200);
|
||||
const summary = summarizeAllowedValues([`${prefix}x`, `${prefix}y`]);
|
||||
expect(summary).not.toBeNull();
|
||||
if (!summary) {
|
||||
return;
|
||||
}
|
||||
expect(summary.hiddenCount).toBe(0);
|
||||
expect(summary.values).toHaveLength(2);
|
||||
expect(summary.values[0]).not.toBe(summary.values[1]);
|
||||
});
|
||||
});
|
||||
98
src/config/allowed-values.ts
Normal file
98
src/config/allowed-values.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const MAX_ALLOWED_VALUES_HINT = 12;
|
||||
const MAX_ALLOWED_VALUE_CHARS = 160;
|
||||
|
||||
export type AllowedValuesSummary = {
|
||||
values: string[];
|
||||
hiddenCount: number;
|
||||
formatted: string;
|
||||
};
|
||||
|
||||
function truncateHintText(text: string, limit: number): string {
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, limit)}... (+${text.length - limit} chars)`;
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
if (serialized !== undefined) {
|
||||
return serialized;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to string coercion when value is not JSON-serializable.
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function toAllowedValueLabel(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return JSON.stringify(truncateHintText(value, MAX_ALLOWED_VALUE_CHARS));
|
||||
}
|
||||
return truncateHintText(safeStringify(value), MAX_ALLOWED_VALUE_CHARS);
|
||||
}
|
||||
|
||||
function toAllowedValueValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
return safeStringify(value);
|
||||
}
|
||||
|
||||
function toAllowedValueDedupKey(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null:null";
|
||||
}
|
||||
const kind = typeof value;
|
||||
if (kind === "string") {
|
||||
return `string:${value as string}`;
|
||||
}
|
||||
return `${kind}:${safeStringify(value)}`;
|
||||
}
|
||||
|
||||
export function summarizeAllowedValues(
|
||||
values: ReadonlyArray<unknown>,
|
||||
): AllowedValuesSummary | null {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deduped: Array<{ value: string; label: string }> = [];
|
||||
const seenValues = new Set<string>();
|
||||
for (const item of values) {
|
||||
const dedupeKey = toAllowedValueDedupKey(item);
|
||||
if (seenValues.has(dedupeKey)) {
|
||||
continue;
|
||||
}
|
||||
seenValues.add(dedupeKey);
|
||||
deduped.push({
|
||||
value: toAllowedValueValue(item),
|
||||
label: toAllowedValueLabel(item),
|
||||
});
|
||||
}
|
||||
|
||||
const shown = deduped.slice(0, MAX_ALLOWED_VALUES_HINT);
|
||||
const hiddenCount = deduped.length - shown.length;
|
||||
const formattedCore = shown.map((entry) => entry.label).join(", ");
|
||||
const formatted =
|
||||
hiddenCount > 0 ? `${formattedCore}, ... (+${hiddenCount} more)` : formattedCore;
|
||||
|
||||
return {
|
||||
values: shown.map((entry) => entry.value),
|
||||
hiddenCount,
|
||||
formatted,
|
||||
};
|
||||
}
|
||||
|
||||
function messageAlreadyIncludesAllowedValues(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return lower.includes("(allowed:") || lower.includes("expected one of");
|
||||
}
|
||||
|
||||
export function appendAllowedValuesHint(message: string, summary: AllowedValuesSummary): string {
|
||||
if (messageAlreadyIncludesAllowedValues(message)) {
|
||||
return message;
|
||||
}
|
||||
return `${message} (allowed: ${summary.formatted})`;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ describe("config plugin validation", () => {
|
||||
let fixtureRoot = "";
|
||||
let suiteHome = "";
|
||||
let badPluginDir = "";
|
||||
let enumPluginDir = "";
|
||||
let bluebubblesPluginDir = "";
|
||||
const envSnapshot = {
|
||||
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
|
||||
@@ -48,6 +49,7 @@ describe("config plugin validation", () => {
|
||||
suiteHome = path.join(fixtureRoot, "home");
|
||||
await fs.mkdir(suiteHome, { recursive: true });
|
||||
badPluginDir = path.join(suiteHome, "bad-plugin");
|
||||
enumPluginDir = path.join(suiteHome, "enum-plugin");
|
||||
bluebubblesPluginDir = path.join(suiteHome, "bluebubbles-plugin");
|
||||
await writePluginFixture({
|
||||
dir: badPluginDir,
|
||||
@@ -61,6 +63,20 @@ describe("config plugin validation", () => {
|
||||
required: ["value"],
|
||||
},
|
||||
});
|
||||
await writePluginFixture({
|
||||
dir: enumPluginDir,
|
||||
id: "enum-plugin",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileFormat: {
|
||||
type: "string",
|
||||
enum: ["markdown", "html"],
|
||||
},
|
||||
},
|
||||
required: ["fileFormat"],
|
||||
},
|
||||
});
|
||||
await writePluginFixture({
|
||||
dir: bluebubblesPluginDir,
|
||||
id: "bluebubbles-plugin",
|
||||
@@ -185,13 +201,34 @@ describe("config plugin validation", () => {
|
||||
if (!res.ok) {
|
||||
const hasIssue = res.issues.some(
|
||||
(issue) =>
|
||||
issue.path === "plugins.entries.bad-plugin.config" &&
|
||||
issue.path.startsWith("plugins.entries.bad-plugin.config") &&
|
||||
issue.message.includes("invalid config"),
|
||||
);
|
||||
expect(hasIssue).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces allowed enum values for plugin config diagnostics", async () => {
|
||||
const res = validateInSuite({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
plugins: {
|
||||
enabled: true,
|
||||
load: { paths: [enumPluginDir] },
|
||||
entries: { "enum-plugin": { config: { fileFormat: "txt" } } },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.issues.find(
|
||||
(entry) => entry.path === "plugins.entries.enum-plugin.config.fileFormat",
|
||||
);
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.message).toContain('allowed: "markdown", "html"');
|
||||
expect(issue?.allowedValues).toEqual(["markdown", "html"]);
|
||||
expect(issue?.allowedValuesHiddenCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts known plugin ids and valid channel/heartbeat enums", async () => {
|
||||
const res = validateInSuite({
|
||||
agents: {
|
||||
|
||||
94
src/config/issue-format.test.ts
Normal file
94
src/config/issue-format.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatConfigIssueLine,
|
||||
formatConfigIssueLines,
|
||||
normalizeConfigIssue,
|
||||
normalizeConfigIssuePath,
|
||||
normalizeConfigIssues,
|
||||
} from "./issue-format.js";
|
||||
|
||||
describe("config issue format", () => {
|
||||
it("normalizes empty paths to <root>", () => {
|
||||
expect(normalizeConfigIssuePath("")).toBe("<root>");
|
||||
expect(normalizeConfigIssuePath(" ")).toBe("<root>");
|
||||
expect(normalizeConfigIssuePath(null)).toBe("<root>");
|
||||
expect(normalizeConfigIssuePath(undefined)).toBe("<root>");
|
||||
});
|
||||
|
||||
it("formats issue lines with and without markers", () => {
|
||||
expect(formatConfigIssueLine({ path: "", message: "broken" }, "-")).toBe("- : broken");
|
||||
expect(
|
||||
formatConfigIssueLine({ path: "", message: "broken" }, "-", { normalizeRoot: true }),
|
||||
).toBe("- <root>: broken");
|
||||
expect(formatConfigIssueLine({ path: "gateway.bind", message: "invalid" }, "")).toBe(
|
||||
"gateway.bind: invalid",
|
||||
);
|
||||
expect(
|
||||
formatConfigIssueLines(
|
||||
[
|
||||
{ path: "", message: "first" },
|
||||
{ path: "channels.signal.dmPolicy", message: "second" },
|
||||
],
|
||||
"×",
|
||||
{ normalizeRoot: true },
|
||||
),
|
||||
).toEqual(["× <root>: first", "× channels.signal.dmPolicy: second"]);
|
||||
});
|
||||
|
||||
it("sanitizes control characters and ANSI sequences in formatted lines", () => {
|
||||
expect(
|
||||
formatConfigIssueLine(
|
||||
{
|
||||
path: "gateway.\nbind\x1b[31m",
|
||||
message: "bad\r\n\tvalue\x1b[0m\u0007",
|
||||
},
|
||||
"-",
|
||||
),
|
||||
).toBe("- gateway.\\nbind: bad\\r\\n\\tvalue");
|
||||
});
|
||||
|
||||
it("normalizes issue metadata for machine output", () => {
|
||||
expect(
|
||||
normalizeConfigIssue({
|
||||
path: "",
|
||||
message: "invalid",
|
||||
allowedValues: ["stable", "beta"],
|
||||
allowedValuesHiddenCount: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
path: "<root>",
|
||||
message: "invalid",
|
||||
allowedValues: ["stable", "beta"],
|
||||
});
|
||||
|
||||
expect(
|
||||
normalizeConfigIssues([
|
||||
{
|
||||
path: "update.channel",
|
||||
message: "invalid",
|
||||
allowedValues: [],
|
||||
allowedValuesHiddenCount: 2,
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
path: "update.channel",
|
||||
message: "invalid",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
normalizeConfigIssue({
|
||||
path: "update.channel",
|
||||
message: "invalid",
|
||||
allowedValues: ["stable"],
|
||||
allowedValuesHiddenCount: 2,
|
||||
}),
|
||||
).toEqual({
|
||||
path: "update.channel",
|
||||
message: "invalid",
|
||||
allowedValues: ["stable"],
|
||||
allowedValuesHiddenCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
68
src/config/issue-format.ts
Normal file
68
src/config/issue-format.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||
import type { ConfigValidationIssue } from "./types.js";
|
||||
|
||||
type ConfigIssueLineInput = {
|
||||
path?: string | null;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ConfigIssueFormatOptions = {
|
||||
normalizeRoot?: boolean;
|
||||
};
|
||||
|
||||
export function normalizeConfigIssuePath(path: string | null | undefined): string {
|
||||
if (typeof path !== "string") {
|
||||
return "<root>";
|
||||
}
|
||||
const trimmed = path.trim();
|
||||
return trimmed ? trimmed : "<root>";
|
||||
}
|
||||
|
||||
export function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValidationIssue {
|
||||
const hasAllowedValues = Array.isArray(issue.allowedValues) && issue.allowedValues.length > 0;
|
||||
return {
|
||||
path: normalizeConfigIssuePath(issue.path),
|
||||
message: issue.message,
|
||||
...(hasAllowedValues ? { allowedValues: issue.allowedValues } : {}),
|
||||
...(hasAllowedValues &&
|
||||
typeof issue.allowedValuesHiddenCount === "number" &&
|
||||
issue.allowedValuesHiddenCount > 0
|
||||
? { allowedValuesHiddenCount: issue.allowedValuesHiddenCount }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConfigIssues(
|
||||
issues: ReadonlyArray<ConfigValidationIssue>,
|
||||
): ConfigValidationIssue[] {
|
||||
return issues.map((issue) => normalizeConfigIssue(issue));
|
||||
}
|
||||
|
||||
function resolveIssuePathForLine(
|
||||
path: string | null | undefined,
|
||||
opts?: ConfigIssueFormatOptions,
|
||||
): string {
|
||||
if (opts?.normalizeRoot) {
|
||||
return normalizeConfigIssuePath(path);
|
||||
}
|
||||
return typeof path === "string" ? path : "";
|
||||
}
|
||||
|
||||
export function formatConfigIssueLine(
|
||||
issue: ConfigIssueLineInput,
|
||||
marker = "-",
|
||||
opts?: ConfigIssueFormatOptions,
|
||||
): string {
|
||||
const prefix = marker ? `${marker} ` : "";
|
||||
const path = sanitizeTerminalText(resolveIssuePathForLine(issue.path, opts));
|
||||
const message = sanitizeTerminalText(issue.message);
|
||||
return `${prefix}${path}: ${message}`;
|
||||
}
|
||||
|
||||
export function formatConfigIssueLines(
|
||||
issues: ReadonlyArray<ConfigIssueLineInput>,
|
||||
marker = "-",
|
||||
opts?: ConfigIssueFormatOptions,
|
||||
): string[] {
|
||||
return issues.map((issue) => formatConfigIssueLine(issue, marker, opts));
|
||||
}
|
||||
@@ -119,6 +119,8 @@ export type OpenClawConfig = {
|
||||
export type ConfigValidationIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
allowedValues?: string[];
|
||||
allowedValuesHiddenCount?: number;
|
||||
};
|
||||
|
||||
export type LegacyConfigIssue = {
|
||||
|
||||
77
src/config/validation.allowed-values.test.ts
Normal file
77
src/config/validation.allowed-values.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigObjectRaw } from "./validation.js";
|
||||
|
||||
describe("config validation allowed-values metadata", () => {
|
||||
it("adds allowed values for invalid union paths", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
update: { channel: "nightly" },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const issue = result.issues.find((entry) => entry.path === "update.channel");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.message).toContain('(allowed: "stable", "beta", "dev")');
|
||||
expect(issue?.allowedValues).toEqual(["stable", "beta", "dev"]);
|
||||
expect(issue?.allowedValuesHiddenCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps native enum messages while attaching allowed values metadata", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
channels: { signal: { dmPolicy: "maybe" } },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const issue = result.issues.find((entry) => entry.path === "channels.signal.dmPolicy");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.message).toContain("expected one of");
|
||||
expect(issue?.message).not.toContain("(allowed:");
|
||||
expect(issue?.allowedValues).toEqual(["pairing", "allowlist", "open", "disabled"]);
|
||||
expect(issue?.allowedValuesHiddenCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes boolean variants for boolean-or-enum unions", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "x",
|
||||
allowFrom: ["*"],
|
||||
dmPolicy: "allowlist",
|
||||
streaming: "maybe",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const issue = result.issues.find((entry) => entry.path === "channels.telegram.streaming");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.allowedValues).toEqual([
|
||||
"true",
|
||||
"false",
|
||||
"off",
|
||||
"partial",
|
||||
"block",
|
||||
"progress",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("skips allowed-values hints for unions with open-ended branches", () => {
|
||||
const result = validateConfigObjectRaw({
|
||||
cron: { sessionRetention: true },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
const issue = result.issues.find((entry) => entry.path === "cron.sessionRetention");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.allowedValues).toBeUndefined();
|
||||
expect(issue?.allowedValuesHiddenCount).toBeUndefined();
|
||||
expect(issue?.message).not.toContain("(allowed:");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
|
||||
import { appendAllowedValuesHint, summarizeAllowedValues } from "./allowed-values.js";
|
||||
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
|
||||
import { findLegacyConfigIssues } from "./legacy.js";
|
||||
import type { OpenClawConfig, ConfigValidationIssue } from "./types.js";
|
||||
@@ -25,6 +26,119 @@ import { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]);
|
||||
|
||||
type UnknownIssueRecord = Record<string, unknown>;
|
||||
type AllowedValuesCollection = {
|
||||
values: unknown[];
|
||||
incomplete: boolean;
|
||||
hasValues: boolean;
|
||||
};
|
||||
|
||||
function toIssueRecord(value: unknown): UnknownIssueRecord | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
return value as UnknownIssueRecord;
|
||||
}
|
||||
|
||||
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
|
||||
const record = toIssueRecord(issue);
|
||||
if (!record) {
|
||||
return { values: [], incomplete: false, hasValues: false };
|
||||
}
|
||||
const code = typeof record.code === "string" ? record.code : "";
|
||||
|
||||
if (code === "invalid_value") {
|
||||
const values = record.values;
|
||||
if (!Array.isArray(values)) {
|
||||
return { values: [], incomplete: true, hasValues: false };
|
||||
}
|
||||
return { values, incomplete: false, hasValues: values.length > 0 };
|
||||
}
|
||||
|
||||
if (code === "invalid_type") {
|
||||
const expected = typeof record.expected === "string" ? record.expected : "";
|
||||
if (expected === "boolean") {
|
||||
return { values: [true, false], incomplete: false, hasValues: true };
|
||||
}
|
||||
return { values: [], incomplete: true, hasValues: false };
|
||||
}
|
||||
|
||||
if (code !== "invalid_union") {
|
||||
return { values: [], incomplete: false, hasValues: false };
|
||||
}
|
||||
|
||||
const nested = record.errors;
|
||||
if (!Array.isArray(nested) || nested.length === 0) {
|
||||
return { values: [], incomplete: true, hasValues: false };
|
||||
}
|
||||
|
||||
const collected: unknown[] = [];
|
||||
for (const branch of nested) {
|
||||
if (!Array.isArray(branch) || branch.length === 0) {
|
||||
return { values: [], incomplete: true, hasValues: false };
|
||||
}
|
||||
const branchCollected = collectAllowedValuesFromIssueList(branch);
|
||||
if (branchCollected.incomplete || !branchCollected.hasValues) {
|
||||
return { values: [], incomplete: true, hasValues: false };
|
||||
}
|
||||
collected.push(...branchCollected.values);
|
||||
}
|
||||
|
||||
return { values: collected, incomplete: false, hasValues: collected.length > 0 };
|
||||
}
|
||||
|
||||
function collectAllowedValuesFromIssueList(
|
||||
issues: ReadonlyArray<unknown>,
|
||||
): AllowedValuesCollection {
|
||||
const collected: unknown[] = [];
|
||||
let hasValues = false;
|
||||
for (const issue of issues) {
|
||||
const branch = collectAllowedValuesFromIssue(issue);
|
||||
if (branch.incomplete) {
|
||||
return { values: [], incomplete: true, hasValues: false };
|
||||
}
|
||||
if (!branch.hasValues) {
|
||||
continue;
|
||||
}
|
||||
hasValues = true;
|
||||
collected.push(...branch.values);
|
||||
}
|
||||
return { values: collected, incomplete: false, hasValues };
|
||||
}
|
||||
|
||||
function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
|
||||
const collection = collectAllowedValuesFromIssue(issue);
|
||||
if (collection.incomplete || !collection.hasValues) {
|
||||
return [];
|
||||
}
|
||||
return collection.values;
|
||||
}
|
||||
|
||||
function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
|
||||
const record = toIssueRecord(issue);
|
||||
const path = Array.isArray(record?.path)
|
||||
? record.path
|
||||
.filter((segment): segment is string | number => {
|
||||
const segmentType = typeof segment;
|
||||
return segmentType === "string" || segmentType === "number";
|
||||
})
|
||||
.join(".")
|
||||
: "";
|
||||
const message = typeof record?.message === "string" ? record.message : "Invalid input";
|
||||
const allowedValuesSummary = summarizeAllowedValues(collectAllowedValuesFromUnknownIssue(issue));
|
||||
|
||||
if (!allowedValuesSummary) {
|
||||
return { path, message };
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
message: appendAllowedValuesHint(message, allowedValuesSummary),
|
||||
allowedValues: allowedValuesSummary.values,
|
||||
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
|
||||
};
|
||||
}
|
||||
|
||||
function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean {
|
||||
const workspaceRoot = path.resolve(workspaceDir);
|
||||
const resolved = path.resolve(workspaceRoot, value);
|
||||
@@ -129,10 +243,7 @@ export function validateConfigObjectRaw(
|
||||
if (!validated.success) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: validated.error.issues.map((iss) => ({
|
||||
path: iss.path.join("."),
|
||||
message: iss.message,
|
||||
})),
|
||||
issues: validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue)),
|
||||
};
|
||||
}
|
||||
const duplicates = findDuplicateAgentDirs(validated.data as OpenClawConfig);
|
||||
@@ -227,6 +338,14 @@ function validateConfigObjectWithPluginsBase(
|
||||
const hasExplicitPluginsConfig =
|
||||
isRecord(raw) && Object.prototype.hasOwnProperty.call(raw, "plugins");
|
||||
|
||||
const resolvePluginConfigIssuePath = (pluginId: string, errorPath: string): string => {
|
||||
const base = `plugins.entries.${pluginId}.config`;
|
||||
if (!errorPath || errorPath === "<root>") {
|
||||
return base;
|
||||
}
|
||||
return `${base}.${errorPath}`;
|
||||
};
|
||||
|
||||
type RegistryInfo = {
|
||||
registry: ReturnType<typeof loadPluginManifestRegistry>;
|
||||
knownIds?: Set<string>;
|
||||
@@ -472,8 +591,10 @@ function validateConfigObjectWithPluginsBase(
|
||||
if (!res.ok) {
|
||||
for (const error of res.errors) {
|
||||
issues.push({
|
||||
path: `plugins.entries.${pluginId}.config`,
|
||||
message: `invalid config: ${error}`,
|
||||
path: resolvePluginConfigIssuePath(pluginId, error.path),
|
||||
message: `invalid config: ${error.message}`,
|
||||
allowedValues: error.allowedValues,
|
||||
allowedValuesHiddenCount: error.allowedValuesHiddenCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import chokidar from "chokidar";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { isPlainObject } from "../utils.js";
|
||||
import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js";
|
||||
|
||||
@@ -141,7 +142,7 @@ export function startGatewayConfigReloader(opts: {
|
||||
if (snapshot.valid) {
|
||||
return false;
|
||||
}
|
||||
const issues = snapshot.issues.map((issue) => `${issue.path}: ${issue.message}`).join(", ");
|
||||
const issues = formatConfigIssueLines(snapshot.issues, "").join(", ");
|
||||
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
readConfigFileSnapshot,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { resolveMainSessionKey } from "../config/sessions.js";
|
||||
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||
@@ -237,9 +238,7 @@ export async function startGatewayServer(
|
||||
if (configSnapshot.exists && !configSnapshot.valid) {
|
||||
const issues =
|
||||
configSnapshot.issues.length > 0
|
||||
? configSnapshot.issues
|
||||
.map((issue) => `${issue.path || "<root>"}: ${issue.message}`)
|
||||
.join("\n")
|
||||
? formatConfigIssueLines(configSnapshot.issues, "", { normalizeRoot: true }).join("\n")
|
||||
: "Unknown validation issue.";
|
||||
throw new Error(
|
||||
`Invalid config at ${configSnapshot.path}.\n${issues}\nRun "${formatCliCommand("openclaw doctor")}" to repair, then retry.`,
|
||||
@@ -332,9 +331,7 @@ export async function startGatewayServer(
|
||||
if (!freshSnapshot.valid) {
|
||||
const issues =
|
||||
freshSnapshot.issues.length > 0
|
||||
? freshSnapshot.issues
|
||||
.map((issue) => `${issue.path || "<root>"}: ${issue.message}`)
|
||||
.join("\n")
|
||||
? formatConfigIssueLines(freshSnapshot.issues, "", { normalizeRoot: true }).join("\n")
|
||||
: "Unknown validation issue.";
|
||||
throw new Error(`Invalid config at ${freshSnapshot.path}.\n${issues}`);
|
||||
}
|
||||
|
||||
16
src/infra/cli-root-options.test.ts
Normal file
16
src/infra/cli-root-options.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { consumeRootOptionToken } from "./cli-root-options.js";
|
||||
|
||||
describe("consumeRootOptionToken", () => {
|
||||
it("consumes boolean and inline root options", () => {
|
||||
expect(consumeRootOptionToken(["--dev"], 0)).toBe(1);
|
||||
expect(consumeRootOptionToken(["--profile=work"], 0)).toBe(1);
|
||||
expect(consumeRootOptionToken(["--log-level=debug"], 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("consumes split root value option only when next token is a value", () => {
|
||||
expect(consumeRootOptionToken(["--profile", "work"], 0)).toBe(2);
|
||||
expect(consumeRootOptionToken(["--profile", "--no-color"], 0)).toBe(1);
|
||||
expect(consumeRootOptionToken(["--profile", "--"], 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
31
src/infra/cli-root-options.ts
Normal file
31
src/infra/cli-root-options.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export const FLAG_TERMINATOR = "--";
|
||||
|
||||
const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]);
|
||||
const ROOT_VALUE_FLAGS = new Set(["--profile", "--log-level"]);
|
||||
|
||||
export function isValueToken(arg: string | undefined): boolean {
|
||||
if (!arg || arg === FLAG_TERMINATOR) {
|
||||
return false;
|
||||
}
|
||||
if (!arg.startsWith("-")) {
|
||||
return true;
|
||||
}
|
||||
return /^-\d+(?:\.\d+)?$/.test(arg);
|
||||
}
|
||||
|
||||
export function consumeRootOptionToken(args: ReadonlyArray<string>, index: number): number {
|
||||
const arg = args[index];
|
||||
if (!arg) {
|
||||
return 0;
|
||||
}
|
||||
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
|
||||
return 1;
|
||||
}
|
||||
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
|
||||
return 1;
|
||||
}
|
||||
if (ROOT_VALUE_FLAGS.has(arg)) {
|
||||
return isValueToken(args[index + 1]) ? 2 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
32
src/logging/logger.settings.test.ts
Normal file
32
src/logging/logger.settings.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./logger.js";
|
||||
|
||||
describe("shouldSkipLoadConfigFallback", () => {
|
||||
it("matches config validate invocations", () => {
|
||||
expect(__test__.shouldSkipLoadConfigFallback(["node", "openclaw", "config", "validate"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles root flags before config validate", () => {
|
||||
expect(
|
||||
__test__.shouldSkipLoadConfigFallback([
|
||||
"node",
|
||||
"openclaw",
|
||||
"--profile",
|
||||
"work",
|
||||
"--no-color",
|
||||
"config",
|
||||
"validate",
|
||||
"--json",
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match other commands", () => {
|
||||
expect(
|
||||
__test__.shouldSkipLoadConfigFallback(["node", "openclaw", "config", "get", "foo"]),
|
||||
).toBe(false);
|
||||
expect(__test__.shouldSkipLoadConfigFallback(["node", "openclaw", "status"])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Logger as TsLogger } from "tslog";
|
||||
import { getCommandPathWithRootOptions } from "../cli/argv.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
|
||||
import { readLoggingConfig } from "./config.js";
|
||||
@@ -42,6 +43,11 @@ export type LogTransport = (logObj: LogTransportRecord) => void;
|
||||
|
||||
const externalTransports = new Set<LogTransport>();
|
||||
|
||||
function shouldSkipLoadConfigFallback(argv: string[] = process.argv): boolean {
|
||||
const [primary, secondary] = getCommandPathWithRootOptions(argv, 2);
|
||||
return primary === "config" && secondary === "validate";
|
||||
}
|
||||
|
||||
function attachExternalTransport(logger: TsLogger<LogObj>, transport: LogTransport): void {
|
||||
logger.attachTransport((logObj: LogObj) => {
|
||||
if (!externalTransports.has(transport)) {
|
||||
@@ -78,7 +84,7 @@ function resolveSettings(): ResolvedSettings {
|
||||
|
||||
let cfg: OpenClawConfig["logging"] | undefined =
|
||||
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
|
||||
if (!cfg) {
|
||||
if (!cfg && !shouldSkipLoadConfigFallback()) {
|
||||
try {
|
||||
const loaded = requireConfig?.("../config/config.js") as
|
||||
| {
|
||||
@@ -289,6 +295,10 @@ export function registerLogTransport(transport: LogTransport): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
shouldSkipLoadConfigFallback,
|
||||
};
|
||||
|
||||
function formatLocalDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
@@ -121,7 +121,7 @@ function validatePluginConfig(params: {
|
||||
if (result.ok) {
|
||||
return { ok: true, value: params.value as Record<string, unknown> | undefined };
|
||||
}
|
||||
return { ok: false, errors: result.errors };
|
||||
return { ok: false, errors: result.errors.map((error) => error.text) };
|
||||
}
|
||||
|
||||
function resolvePluginModuleExport(moduleExport: unknown): {
|
||||
|
||||
211
src/plugins/schema-validator.test.ts
Normal file
211
src/plugins/schema-validator.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "./schema-validator.js";
|
||||
|
||||
describe("schema validator", () => {
|
||||
it("includes allowed values in enum validation errors", () => {
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.enum",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileFormat: {
|
||||
type: "string",
|
||||
enum: ["markdown", "html", "json"],
|
||||
},
|
||||
},
|
||||
required: ["fileFormat"],
|
||||
},
|
||||
value: { fileFormat: "txt" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors.find((entry) => entry.path === "fileFormat");
|
||||
expect(issue?.message).toContain("(allowed:");
|
||||
expect(issue?.allowedValues).toEqual(["markdown", "html", "json"]);
|
||||
expect(issue?.allowedValuesHiddenCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes allowed value in const validation errors", () => {
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.const",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
const: "strict",
|
||||
},
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
value: { mode: "relaxed" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors.find((entry) => entry.path === "mode");
|
||||
expect(issue?.message).toContain("(allowed:");
|
||||
expect(issue?.allowedValues).toEqual(["strict"]);
|
||||
expect(issue?.allowedValuesHiddenCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long allowed-value hints", () => {
|
||||
const values = [
|
||||
"v1",
|
||||
"v2",
|
||||
"v3",
|
||||
"v4",
|
||||
"v5",
|
||||
"v6",
|
||||
"v7",
|
||||
"v8",
|
||||
"v9",
|
||||
"v10",
|
||||
"v11",
|
||||
"v12",
|
||||
"v13",
|
||||
];
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.enum.truncate",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: values,
|
||||
},
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
value: { mode: "not-listed" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors.find((entry) => entry.path === "mode");
|
||||
expect(issue?.message).toContain("(allowed:");
|
||||
expect(issue?.message).toContain("... (+1 more)");
|
||||
expect(issue?.allowedValues).toEqual([
|
||||
"v1",
|
||||
"v2",
|
||||
"v3",
|
||||
"v4",
|
||||
"v5",
|
||||
"v6",
|
||||
"v7",
|
||||
"v8",
|
||||
"v9",
|
||||
"v10",
|
||||
"v11",
|
||||
"v12",
|
||||
]);
|
||||
expect(issue?.allowedValuesHiddenCount).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("appends missing required property to the structured path", () => {
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.required.path",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: { type: "string" },
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
},
|
||||
required: ["settings"],
|
||||
},
|
||||
value: { settings: {} },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors.find((entry) => entry.path === "settings.mode");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.allowedValues).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("appends missing dependency property to the structured path", () => {
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.dependencies.path",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
settings: {
|
||||
type: "object",
|
||||
dependencies: {
|
||||
mode: ["format"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
value: { settings: { mode: "strict" } },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors.find((entry) => entry.path === "settings.format");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.allowedValues).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates oversized allowed value entries", () => {
|
||||
const oversizedAllowed = "a".repeat(300);
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.enum.long-value",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: [oversizedAllowed],
|
||||
},
|
||||
},
|
||||
required: ["mode"],
|
||||
},
|
||||
value: { mode: "not-listed" },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors.find((entry) => entry.path === "mode");
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.message).toContain("(allowed:");
|
||||
expect(issue?.message).toContain("... (+");
|
||||
}
|
||||
});
|
||||
|
||||
it("sanitizes terminal text while preserving structured fields", () => {
|
||||
const maliciousProperty = "evil\nkey\t\x1b[31mred\x1b[0m";
|
||||
const res = validateJsonSchemaValue({
|
||||
cacheKey: "schema-validator.test.terminal-sanitize",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [maliciousProperty],
|
||||
},
|
||||
value: {},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
const issue = res.errors[0];
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.path).toContain("\n");
|
||||
expect(issue?.message).toContain("\n");
|
||||
expect(issue?.text).toContain("\\n");
|
||||
expect(issue?.text).toContain("\\t");
|
||||
expect(issue?.text).not.toContain("\n");
|
||||
expect(issue?.text).not.toContain("\t");
|
||||
expect(issue?.text).not.toContain("\x1b");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createRequire } from "node:module";
|
||||
import type { ErrorObject, ValidateFunction } from "ajv";
|
||||
import { appendAllowedValuesHint, summarizeAllowedValues } from "../config/allowed-values.js";
|
||||
import { sanitizeTerminalText } from "../terminal/safe-text.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
type AjvLike = {
|
||||
@@ -31,14 +33,100 @@ type CachedValidator = {
|
||||
|
||||
const schemaCache = new Map<string, CachedValidator>();
|
||||
|
||||
function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
|
||||
export type JsonSchemaValidationError = {
|
||||
path: string;
|
||||
message: string;
|
||||
text: string;
|
||||
allowedValues?: string[];
|
||||
allowedValuesHiddenCount?: number;
|
||||
};
|
||||
|
||||
function normalizeAjvPath(instancePath: string | undefined): string {
|
||||
const path = instancePath?.replace(/^\//, "").replace(/\//g, ".");
|
||||
return path && path.length > 0 ? path : "<root>";
|
||||
}
|
||||
|
||||
function appendPathSegment(path: string, segment: string): string {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
return path;
|
||||
}
|
||||
if (path === "<root>") {
|
||||
return trimmed;
|
||||
}
|
||||
return `${path}.${trimmed}`;
|
||||
}
|
||||
|
||||
function resolveMissingProperty(error: ErrorObject): string | null {
|
||||
if (
|
||||
error.keyword !== "required" &&
|
||||
error.keyword !== "dependentRequired" &&
|
||||
error.keyword !== "dependencies"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const missingProperty = (error.params as { missingProperty?: unknown }).missingProperty;
|
||||
return typeof missingProperty === "string" && missingProperty.trim() ? missingProperty : null;
|
||||
}
|
||||
|
||||
function resolveAjvErrorPath(error: ErrorObject): string {
|
||||
const basePath = normalizeAjvPath(error.instancePath);
|
||||
const missingProperty = resolveMissingProperty(error);
|
||||
if (!missingProperty) {
|
||||
return basePath;
|
||||
}
|
||||
return appendPathSegment(basePath, missingProperty);
|
||||
}
|
||||
|
||||
function extractAllowedValues(error: ErrorObject): unknown[] | null {
|
||||
if (error.keyword === "enum") {
|
||||
const allowedValues = (error.params as { allowedValues?: unknown }).allowedValues;
|
||||
return Array.isArray(allowedValues) ? allowedValues : null;
|
||||
}
|
||||
|
||||
if (error.keyword === "const") {
|
||||
const params = error.params as { allowedValue?: unknown };
|
||||
if (!Object.prototype.hasOwnProperty.call(params, "allowedValue")) {
|
||||
return null;
|
||||
}
|
||||
return [params.allowedValue];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType<typeof summarizeAllowedValues> {
|
||||
const allowedValues = extractAllowedValues(error);
|
||||
if (!allowedValues) {
|
||||
return null;
|
||||
}
|
||||
return summarizeAllowedValues(allowedValues);
|
||||
}
|
||||
|
||||
function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaValidationError[] {
|
||||
if (!errors || errors.length === 0) {
|
||||
return ["invalid config"];
|
||||
return [{ path: "<root>", message: "invalid config", text: "<root>: invalid config" }];
|
||||
}
|
||||
return errors.map((error) => {
|
||||
const path = error.instancePath?.replace(/^\//, "").replace(/\//g, ".") || "<root>";
|
||||
const message = error.message ?? "invalid";
|
||||
return `${path}: ${message}`;
|
||||
const path = resolveAjvErrorPath(error);
|
||||
const baseMessage = error.message ?? "invalid";
|
||||
const allowedValuesSummary = getAjvAllowedValuesSummary(error);
|
||||
const message = allowedValuesSummary
|
||||
? appendAllowedValuesHint(baseMessage, allowedValuesSummary)
|
||||
: baseMessage;
|
||||
const safePath = sanitizeTerminalText(path);
|
||||
const safeMessage = sanitizeTerminalText(message);
|
||||
return {
|
||||
path,
|
||||
message,
|
||||
text: `${safePath}: ${safeMessage}`,
|
||||
...(allowedValuesSummary
|
||||
? {
|
||||
allowedValues: allowedValuesSummary.values,
|
||||
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,7 +134,7 @@ export function validateJsonSchemaValue(params: {
|
||||
schema: Record<string, unknown>;
|
||||
cacheKey: string;
|
||||
value: unknown;
|
||||
}): { ok: true } | { ok: false; errors: string[] } {
|
||||
}): { ok: true } | { ok: false; errors: JsonSchemaValidationError[] } {
|
||||
let cached = schemaCache.get(params.cacheKey);
|
||||
if (!cached || cached.schema !== params.schema) {
|
||||
const validate = getAjv().compile(params.schema);
|
||||
|
||||
12
src/terminal/safe-text.test.ts
Normal file
12
src/terminal/safe-text.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeTerminalText } from "./safe-text.js";
|
||||
|
||||
describe("sanitizeTerminalText", () => {
|
||||
it("removes C1 control characters", () => {
|
||||
expect(sanitizeTerminalText("a\u009bb\u0085c")).toBe("abc");
|
||||
});
|
||||
|
||||
it("escapes line controls while preserving printable text", () => {
|
||||
expect(sanitizeTerminalText("a\tb\nc\rd")).toBe("a\\tb\\nc\\rd");
|
||||
});
|
||||
});
|
||||
20
src/terminal/safe-text.ts
Normal file
20
src/terminal/safe-text.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { stripAnsi } from "./ansi.js";
|
||||
|
||||
/**
|
||||
* Normalize untrusted text for single-line terminal/log rendering.
|
||||
*/
|
||||
export function sanitizeTerminalText(input: string): string {
|
||||
const normalized = stripAnsi(input)
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\t/g, "\\t");
|
||||
let sanitized = "";
|
||||
for (const char of normalized) {
|
||||
const code = char.charCodeAt(0);
|
||||
const isControl = (code >= 0x00 && code <= 0x1f) || (code >= 0x7f && code <= 0x9f);
|
||||
if (!isControl) {
|
||||
sanitized += char;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
Reference in New Issue
Block a user