diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 4a8d9fd29eb..e5c863d340a 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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); diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 50d00ed0ff0..81c309a92a6 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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[] { diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts index 1a9e00fb670..7960b60ac7c 100644 --- a/src/cli/program/route-args.test.ts +++ b/src/cli/program/route-args.test.ts @@ -44,6 +44,48 @@ describe("route-args", () => { expect(parseStatusRouteArgs(["node", "openclaw", "status", "--timeout"])).toBeNull(); }); + it("defers status/health --timeout with a present-but-invalid value to Commander", () => { + // Regression: the route-first fast path used to silently accept invalid + // --timeout values (0, negative, non-numeric, unit-suffixed) and run with + // the default timeout, diverging from the full Commander path which rejects + // them with a non-zero exit. Returning null defers to Commander so both + // paths share the same validation. + for (const bad of ["0", "-5", "nope", "5s"]) { + 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 }, + ); + // No --timeout flag at all still uses the fast path (undefined timeout). + expect(parseStatusRouteArgs(["node", "openclaw", "status"])).toMatchObject({ + timeoutMs: undefined, + }); + }); + it("parses gateway status route args and rejects probe-only ssh flags", () => { expect( parseGatewayStatusRouteArgs([