From ff3ed99cde0190a1df5b88be3cc076326667ca83 Mon Sep 17 00:00:00 2001 From: sheyanmin Date: Sun, 19 Jul 2026 04:57:11 +0800 Subject: [PATCH] fix(usage-bar): validate meter width with strict integer parsing (#110410) * fix(usage-bar): validate meter width with strict integer parsing * fix(usage-bar): restore canonical meter width range [0, 100] * fix(usage-bar): preserve bounded meter defaults Co-authored-by: sheyanmin --------- Co-authored-by: Peter Steinberger --- docs/concepts/usage-tracking.md | 3 +++ src/auto-reply/usage-bar/translator.test.ts | 15 ++++++++++++++ src/auto-reply/usage-bar/translator.ts | 22 ++++++++++++++------- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 6d4b8df1e7ba..6b7d1b898913 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -256,6 +256,9 @@ Pipe a value through verbs left to right; a non-verb segment is the fallback. `fixed:N` accepts only a complete decimal integer from 0 through 100. Invalid precision arguments make that interpolation empty. +`meter:W:SCALE` accepts only a complete decimal integer width from 1 through 100. Leave the width blank to use the default 5 (`meter::braille`); invalid +widths make that interpolation empty. + ### Piece forms - `{ "text": "📚 {context.max_tokens|num}" }`: literal + interpolation. diff --git a/src/auto-reply/usage-bar/translator.test.ts b/src/auto-reply/usage-bar/translator.test.ts index ec42834a44f3..90a94d970642 100644 --- a/src/auto-reply/usage-bar/translator.test.ts +++ b/src/auto-reply/usage-bar/translator.test.ts @@ -72,6 +72,21 @@ describe("usage-bar verbs", () => { expect(render([{ text: "{x|meter:1:moon}" }], { x: 100 })).toBe("🌕"); }); + it("meter — preserves default and supported explicit widths", () => { + expect(render([{ text: "{x|meter::braille}" }], { x: 75 })).toBe("⣿⣿⣿⣧⠐"); + expect(render([{ text: "{x|meter: :braille}" }], { x: 75 })).toBe("⣿⣿⣿⣧⠐"); + expect(render([{ text: "{x|meter:+5:braille}" }], { x: 75 })).toBe("⣿⣿⣿⣧⠐"); + expect(render([{ text: "{x|meter: 5 :braille}" }], { x: 75 })).toBe("⣿⣿⣿⣧⠐"); + expect(render([{ text: "{x|meter:100:braille}" }], { x: 50 })).toHaveLength(100); + }); + + it.each(["0", "-1", "2.5", "101", "1e2", "2junk", "abc", "9007199254740992"])( + "meter — rejects invalid width %s", + (width) => { + expect(render([{ text: `{x|meter:${width}:braille}` }], { x: 75 })).toBe(""); + }, + ); + it("alias — listed shortens, unlisted echoes through", () => { expect(render([{ text: "{m|alias:models}" }], { m: "claude-opus-4-6" })).toBe("opus46"); expect(render([{ text: "{m|alias:models}" }], { m: "some-new-model" })).toBe("some-new-model"); diff --git a/src/auto-reply/usage-bar/translator.ts b/src/auto-reply/usage-bar/translator.ts index 279808746a63..578ecb8db970 100644 --- a/src/auto-reply/usage-bar/translator.ts +++ b/src/auto-reply/usage-bar/translator.ts @@ -1,4 +1,8 @@ -import { expectDefined, parseStrictInteger } from "@openclaw/normalization-core"; +import { + asSafeIntegerInRange, + expectDefined, + parseStrictInteger, +} from "@openclaw/normalization-core"; export type UsageBarTemplate = Record; export type UsageContract = Record; type Vocab = Record; @@ -117,9 +121,12 @@ function meter(value: unknown, width: number, scale: unknown): string { const VERB_NAMES = new Set(["num", "fixed", "dur", "pct", "inv", "alias", "meter"]); -function parseFixedDigits(raw: string | undefined): number | undefined { - const digits = raw === undefined ? 2 : parseStrictInteger(raw); - return digits !== undefined && digits >= 0 && digits <= 100 ? digits : undefined; +function parseBoundedIntegerArg( + raw: string | undefined, + options: { defaultValue: number; min: number; max: number }, +): number | undefined { + const value = raw === undefined ? options.defaultValue : parseStrictInteger(raw); + return asSafeIntegerInRange(value, options); } function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): unknown { @@ -127,7 +134,7 @@ function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): case "num": return num(value); case "fixed": { - const digits = parseFixedDigits(args[0]); + const digits = parseBoundedIntegerArg(args[0], { defaultValue: 2, min: 0, max: 100 }); return digits === undefined ? "" : fixed(value, digits); } case "dur": @@ -148,9 +155,10 @@ function applyVerb(name: string, args: string[], value: unknown, vocab: Vocab): return Object.hasOwn(table, lower) ? table[lower] : value; } case "meter": { - const width = args[0] ? Number.parseInt(args[0], 10) || 5 : 5; + const rawWidth = args[0]?.trim() ? args[0] : undefined; + const width = parseBoundedIntegerArg(rawWidth, { defaultValue: 5, min: 1, max: 100 }); const scale = args.length > 1 ? vocab[expectDefined(args[1], "args entry at 1")] : undefined; - return meter(value, width, scale); + return width === undefined ? "" : meter(value, width, scale); } default: return String(value);