CLI/Config: handle root options in validate bypass and union hints

This commit is contained in:
Gustavo Madeira Santana
2026-03-02 14:59:50 -05:00
parent 741c57eaf8
commit 7ee245c272
7 changed files with 154 additions and 48 deletions

View File

@@ -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",

View File

@@ -170,6 +170,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 +192,21 @@ export function getCommandPath(argv: string[], depth = 2): string[] {
if (arg === "--") {
break;
}
if (opts.skipRootOptions) {
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
continue;
}
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
continue;
}
if (ROOT_VALUE_FLAGS.has(arg)) {
const next = args[i + 1];
if (isValueToken(next)) {
i += 1;
}
continue;
}
}
if (arg.startsWith("-")) {
continue;
}

View File

@@ -217,6 +217,15 @@ describe("registerPreActionHooks", () => {
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 = (

View File

@@ -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";
@@ -98,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" ||

View File

@@ -32,4 +32,46 @@ describe("config validation allowed-values metadata", () => {
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:");
}
});
});

View File

@@ -27,6 +27,11 @@ 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") {
@@ -35,37 +40,78 @@ function toIssueRecord(value: unknown): UnknownIssueRecord | null {
return value as UnknownIssueRecord;
}
function collectAllowedValuesFromUnknownIssue(issue: unknown): unknown[] {
function collectAllowedValuesFromIssue(issue: unknown): AllowedValuesCollection {
const record = toIssueRecord(issue);
if (!record) {
return [];
return { values: [], incomplete: false, hasValues: false };
}
const code = typeof record.code === "string" ? record.code : "";
if (code === "invalid_value") {
const values = record.values;
return Array.isArray(values) ? 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 [];
return { values: [], incomplete: false, hasValues: false };
}
const nested = record.errors;
if (!Array.isArray(nested)) {
return [];
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)) {
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;
}
for (const nestedIssue of branch) {
collected.push(...collectAllowedValuesFromUnknownIssue(nestedIssue));
}
hasValues = true;
collected.push(...branch.values);
}
return collected;
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 {

View File

@@ -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,42 +43,8 @@ export type LogTransport = (logObj: LogTransportRecord) => void;
const externalTransports = new Set<LogTransport>();
function getCommandPathFromArgv(argv: string[]): string[] {
const tokens: string[] = [];
let skipNextAsRootValue = false;
for (const arg of argv.slice(2)) {
if (!arg || arg === "--") {
break;
}
if (skipNextAsRootValue) {
skipNextAsRootValue = false;
continue;
}
if (arg === "--profile" || arg === "--log-level") {
skipNextAsRootValue = true;
continue;
}
if (
arg === "--dev" ||
arg === "--no-color" ||
arg.startsWith("--profile=") ||
arg.startsWith("--log-level=")
) {
continue;
}
if (arg.startsWith("-")) {
continue;
}
tokens.push(arg);
if (tokens.length >= 2) {
break;
}
}
return tokens;
}
function shouldSkipLoadConfigFallback(argv: string[] = process.argv): boolean {
const [primary, secondary] = getCommandPathFromArgv(argv);
const [primary, secondary] = getCommandPathWithRootOptions(argv, 2);
return primary === "config" && secondary === "validate";
}