fix(cli): preserve timeout validation on routed commands

This commit is contained in:
Peter Steinberger
2026-06-19 14:14:22 -04:00
parent e866d230e8
commit eda96f9f80
4 changed files with 76 additions and 28 deletions

View File

@@ -561,6 +561,16 @@ describe("argv helpers", () => {
argv: ["node", "openclaw", "--", "--timeout=99"],
expected: undefined,
},
{
name: "repeated flag uses final value",
argv: ["node", "openclaw", "status", "--timeout", "100", "--timeout=200"],
expected: "200",
},
{
name: "missing repeated value remains invalid",
argv: ["node", "openclaw", "status", "--timeout", "--timeout", "200"],
expected: null,
},
])("extracts flag values: $name", ({ argv, expected }) => {
expect(getFlagValue(argv, "--timeout")).toBe(expected);
});
@@ -597,17 +607,37 @@ describe("argv helpers", () => {
{
name: "invalid integer",
argv: ["node", "openclaw", "status", "--timeout", "nope"],
expected: undefined,
expected: null,
},
{
name: "non-decimal integer",
argv: ["node", "openclaw", "status", "--timeout", "0x10"],
expected: undefined,
expected: null,
},
{
name: "partial integer",
argv: ["node", "openclaw", "status", "--timeout", "5s"],
expected: undefined,
expected: null,
},
{
name: "zero",
argv: ["node", "openclaw", "status", "--timeout", "0"],
expected: null,
},
{
name: "negative integer",
argv: ["node", "openclaw", "status", "--timeout", "-5"],
expected: null,
},
{
name: "repeated value uses final valid integer",
argv: ["node", "openclaw", "status", "--timeout", "nope", "--timeout", "5000"],
expected: 5000,
},
{
name: "repeated value rejects final invalid integer",
argv: ["node", "openclaw", "status", "--timeout", "5000", "--timeout", "nope"],
expected: null,
},
])("parses positive integer flag values: $name", ({ argv, expected }) => {
expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected);

View File

@@ -403,6 +403,7 @@ export function normalizeRootLogLevelArgv(
export function getFlagValue(argv: string[], name: string): string | null | undefined {
const args = argv.slice(2);
let value: string | undefined;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === FLAG_TERMINATOR) {
@@ -410,14 +411,22 @@ export function getFlagValue(argv: string[], name: string): string | null | unde
}
if (arg === name) {
const next = args[i + 1];
return isValueToken(next) ? next : null;
if (!isValueToken(next)) {
return null;
}
value = next;
i += 1;
continue;
}
if (arg.startsWith(`${name}=`)) {
const value = arg.slice(name.length + 1);
return value ? value : null;
const assigned = arg.slice(name.length + 1);
if (!assigned) {
return null;
}
value = assigned;
}
}
return undefined;
return value;
}
export function getVerboseFlag(argv: string[], options?: { includeDebug?: boolean }): boolean {
@@ -435,7 +444,9 @@ export function getPositiveIntFlagValue(argv: string[], name: string): number |
if (raw === null || raw === undefined) {
return raw;
}
return parsePositiveInt(raw);
// Keep absent distinct from present-but-invalid so route-first callers can
// defer invalid input to Commander instead of silently applying defaults.
return parsePositiveInt(raw) ?? null;
}
export function getCommandPathWithRootOptions(argv: string[], depth = 2): string[] {

View File

@@ -54,6 +54,28 @@ describe("route-args", () => {
expect(parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", bad])).toBeNull();
expect(parseHealthRouteArgs(["node", "openclaw", "health", "--timeout", bad])).toBeNull();
}
expect(
parseStatusRouteArgs([
"node",
"openclaw",
"status",
"--timeout",
"5000",
"--timeout",
"nope",
]),
).toBeNull();
expect(
parseHealthRouteArgs([
"node",
"openclaw",
"health",
"--timeout",
"nope",
"--timeout",
"5000",
]),
).toMatchObject({ timeoutMs: 5000 });
// A valid positive integer still parses on the fast path.
expect(parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", "5000"])).toMatchObject(
{ timeoutMs: 5000 },

View File

@@ -3,6 +3,7 @@ import { isValueToken } from "../../infra/cli-root-options.js";
import {
getCommandPositionalsWithRootOptions,
getFlagValue,
getPositiveIntFlagValue,
getVerboseFlag,
hasFlag,
} from "../argv.js";
@@ -66,16 +67,8 @@ function parseSinglePositional(
/** Parse `openclaw health` flags for the route-first status family. */
export function parseHealthRouteArgs(argv: string[]) {
// A present-but-invalid --timeout (0, negative, non-numeric) must defer to
// Commander so it surfaces the same validation error as the full command
// path, instead of the fast path silently falling back to the default.
const rawTimeout = getFlagValue(argv, "--timeout");
if (rawTimeout === null) {
return null;
}
const timeoutMs =
rawTimeout === undefined ? undefined : parseStrictPositiveIntOrUndefined(rawTimeout);
if (rawTimeout !== undefined && timeoutMs === undefined) {
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) {
return null;
}
return {
@@ -87,16 +80,8 @@ export function parseHealthRouteArgs(argv: string[]) {
/** Parse `openclaw status` flags without registering the full command tree. */
export function parseStatusRouteArgs(argv: string[]) {
// A present-but-invalid --timeout (0, negative, non-numeric) must defer to
// Commander so it surfaces the same validation error as the full command
// path, instead of the fast path silently falling back to the default.
const rawTimeout = getFlagValue(argv, "--timeout");
if (rawTimeout === null) {
return null;
}
const timeoutMs =
rawTimeout === undefined ? undefined : parseStrictPositiveIntOrUndefined(rawTimeout);
if (rawTimeout !== undefined && timeoutMs === undefined) {
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) {
return null;
}
return {