diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index fd7ed71d529..35fad29a546 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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", diff --git a/src/cli/argv.ts b/src/cli/argv.ts index ecc33d689e5..a2f77c15579 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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; } diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 5761b47dc9a..065abb3bbf7 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -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 = ( diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index fd4bc9de3c5..5984df6e4f4 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -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" || diff --git a/src/config/validation.allowed-values.test.ts b/src/config/validation.allowed-values.test.ts index 360b78238d4..d586246ff87 100644 --- a/src/config/validation.allowed-values.test.ts +++ b/src/config/validation.allowed-values.test.ts @@ -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:"); + } + }); }); diff --git a/src/config/validation.ts b/src/config/validation.ts index a3065304514..391c0afa99c 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -27,6 +27,11 @@ import { OpenClawSchema } from "./zod-schema.js"; const LEGACY_REMOVED_PLUGIN_IDS = new Set(["google-antigravity-auth"]); type UnknownIssueRecord = Record; +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, +): 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 { diff --git a/src/logging/logger.ts b/src/logging/logger.ts index 452042cc25f..47e5624dc20 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -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(); -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"; }