mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-02 12:51:57 +00:00
CLI/Config: handle root options in validate bypass and union hints
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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:");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user