fix: reject partial numeric runtime values

This commit is contained in:
Peter Steinberger
2026-05-27 20:04:05 -04:00
parent d1aa3cb925
commit 45e6af5e57
16 changed files with 159 additions and 23 deletions

View File

@@ -37,7 +37,11 @@ function getDiscordDeliveryRetryAfterMs(err: unknown): number | undefined {
if (!retryAfterRaw) {
return undefined;
}
const retryAfterMs = Number(retryAfterRaw) * 1000;
const trimmedRetryAfter = retryAfterRaw.trim();
if (!/^\d+(?:\.\d+)?$/.test(trimmedRetryAfter)) {
return undefined;
}
const retryAfterMs = Number(trimmedRetryAfter) * 1000;
return Number.isFinite(retryAfterMs) ? retryAfterMs : undefined;
}

View File

@@ -3,7 +3,12 @@ import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js";
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
import {
buildMSTeamsPollCard,
createMSTeamsPollStoreFs,
extractMSTeamsPollVote,
normalizeMSTeamsPollSelections,
} from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
import { msteamsRuntimeStub } from "./test-runtime.js";
@@ -60,6 +65,22 @@ describe("msteams polls", () => {
}
expect(stored.votes["user-1"]).toEqual(["0"]);
});
it("does not coerce partial poll selections", () => {
expect(
normalizeMSTeamsPollSelections(
{
id: "poll-1",
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
votes: {},
createdAt: "2026-03-22T00:00:00.000Z",
},
["0", "1x"],
),
).toEqual(["0"]);
});
});
const createFsStore = async () => {

View File

@@ -253,8 +253,11 @@ function pruneToLimit(polls: Record<string, MSTeamsPoll>) {
export function normalizeMSTeamsPollSelections(poll: MSTeamsPoll, selections: string[]) {
const maxSelections = Math.max(1, poll.maxSelections);
const mapped = selections
.map((entry) => Number.parseInt(entry, 10))
.filter((value) => Number.isFinite(value))
.map((entry) => {
const trimmed = entry.trim();
return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
})
.filter((value) => Number.isSafeInteger(value))
.filter((value) => value >= 0 && value < poll.options.length)
.map((value) => String(value));
const limited = maxSelections > 1 ? mapped.slice(0, maxSelections) : mapped.slice(0, 1);

View File

@@ -367,6 +367,16 @@ describe("TwilioProvider", () => {
expect(parsed.turnToken).toBe("turn-xyz");
});
it("does not coerce partial Twilio speech confidence values", () => {
const provider = createProvider();
const ctx = createContext("CallSid=CA223&Direction=inbound&SpeechResult=hello&Confidence=0.2x");
const event = provider.parseWebhookEvent(ctx).events[0];
const parsed = requireEvent(event, "expected speech event from Twilio webhook");
expect(parsed.type).toBe("call.speech");
expect(parsed.confidence).toBe(0.9);
});
it("fails when an active stream exists but telephony TTS is unavailable", async () => {
const { provider, apiRequest } = configureTelephonyTwiMlFallback({
providerCallId: "CA-stream",

View File

@@ -319,6 +319,14 @@ export class TwilioProvider implements VoiceCallProvider {
return undefined;
}
private static parseConfidence(value: string | null): number {
const trimmed = value?.trim();
if (!trimmed || !/^\d+(?:\.\d+)?$/.test(trimmed)) {
return 0.9;
}
return Number(trimmed);
}
/**
* Convert Twilio webhook params to normalized event format.
*/
@@ -353,7 +361,7 @@ export class TwilioProvider implements VoiceCallProvider {
type: "call.speech",
transcript: speechResult,
isFinal: true,
confidence: Number.parseFloat(params.get("Confidence") || "0.9"),
confidence: TwilioProvider.parseConfidence(params.get("Confidence")),
};
}

View File

@@ -210,6 +210,27 @@ describe("xAI OAuth", () => {
expect(refreshed.expires).toBe(121_000);
});
it("does not coerce partial xAI expires_in values", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
jsonResponse({
access_token: "access-2",
expires_in: "120s",
}),
);
const credential = {
type: "oauth",
provider: "xai",
access: "access-1",
refresh: "refresh-1",
expires: 100,
tokenEndpoint: "https://auth.x.ai/oauth2/token",
} satisfies OAuthCredential & { tokenEndpoint: string };
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
expect(refreshed.expires).toBe(100);
});
it("prints the authorize URL through plain prompter output so terminal link detection keeps it whole", async () => {
waitForLocalOAuthCallbackMock.mockResolvedValue({ code: "AUTHCODE", state: "state-1" });
stubSuccessfulXaiOAuthNetwork();

View File

@@ -216,7 +216,7 @@ function normalizeExpires(value: unknown, now: () => number): number | undefined
typeof value === "number"
? value
: typeof value === "string"
? Number.parseFloat(value)
? parsePositiveSeconds(value)
: Number.NaN;
if (!Number.isFinite(seconds) || seconds <= 0) {
return undefined;
@@ -229,7 +229,7 @@ function normalizePositiveSecondsToMs(value: unknown): number | undefined {
typeof value === "number"
? value
: typeof value === "string"
? Number.parseFloat(value)
? parsePositiveSeconds(value)
: Number.NaN;
if (!Number.isFinite(seconds) || seconds <= 0) {
return undefined;
@@ -237,6 +237,14 @@ function normalizePositiveSecondsToMs(value: unknown): number | undefined {
return Math.trunc(seconds * 1000);
}
function parsePositiveSeconds(raw: string): number {
const trimmed = raw.trim();
if (!/^\d+(?:\.\d+)?$/.test(trimmed)) {
return Number.NaN;
}
return Number(trimmed);
}
function parseXaiOAuthTokenResponse(
value: unknown,
now: () => number,

View File

@@ -58,6 +58,33 @@ describe("openai responses payload policy", () => {
});
});
it("does not coerce partial context windows for compaction thresholds", () => {
const model = {
id: "gpt-5.4",
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
contextWindow: "200000tokens",
} satisfies Pick<
Model<"openai-responses">,
"api" | "baseUrl" | "contextWindow" | "id" | "provider"
>;
const payload = {} satisfies Record<string, unknown>;
applyOpenAIResponsesPayloadPolicy(
payload,
resolveOpenAIResponsesPayloadPolicy(model, {
enableServerCompaction: true,
storeMode: "provider-policy",
}),
);
expect(payload).toEqual({
store: true,
context_management: [{ type: "compaction", compact_threshold: 80_000 }],
});
});
it("strips store and prompt cache for proxy-like responses routes when requested", () => {
const policy = resolveOpenAIResponsesPayloadPolicy(
{

View File

@@ -268,8 +268,9 @@ function parsePositiveInteger(value: unknown): number | undefined {
return Math.floor(value);
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
const trimmed = value.trim();
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
if (Number.isSafeInteger(parsed) && parsed > 0) {
return parsed;
}
}

View File

@@ -480,6 +480,11 @@ describe("argv helpers", () => {
argv: ["node", "openclaw", "status", "--timeout", "nope"],
expected: undefined,
},
{
name: "partial integer",
argv: ["node", "openclaw", "status", "--timeout", "5s"],
expected: undefined,
},
])("parses positive integer flag values: $name", ({ argv, expected }) => {
expect(getPositiveIntFlagValue(argv, "--timeout")).toBe(expected);
});

View File

@@ -76,11 +76,12 @@ export function isHelpOrVersionInvocation(argv: string[]): boolean {
}
function parsePositiveInt(value: string): number | undefined {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return undefined;
}
return parsed;
const parsed = Number(trimmed);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
export function hasFlag(argv: string[], name: string): boolean {

View File

@@ -217,6 +217,26 @@ describe("SessionHistorySseState", () => {
).toBe("Cursor-visible reply.");
});
test("does not coerce partial cursor values", () => {
const snapshot = buildSessionHistorySnapshot({
rawMessages: [
{
role: "assistant",
content: [{ type: "text", text: "first" }],
__openclaw: { seq: 1 },
},
{
role: "assistant",
content: [{ type: "text", text: "second" }],
__openclaw: { seq: 2 },
},
],
cursor: "seq:2next",
});
expect(snapshot.history.messages.map((message) => message["__openclaw"]?.seq)).toEqual([1, 2]);
});
test("requests refresh when silent control reply completes multiple message-tool mirrors", () => {
const state = SessionHistorySseState.fromRawSnapshot({
target: { sessionId: "sess-main" },

View File

@@ -64,8 +64,11 @@ function resolveCursorSeq(cursor: string | undefined): number | undefined {
return undefined;
}
const normalized = cursor.startsWith("seq:") ? cursor.slice(4) : cursor;
const value = Number.parseInt(normalized, 10);
return Number.isFinite(value) && value > 0 ? value : undefined;
if (!/^\d+$/.test(normalized)) {
return undefined;
}
const value = Number(normalized);
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
function toSessionHistoryMessages(messages: unknown[]): SessionHistoryMessage[] {

View File

@@ -69,8 +69,9 @@ function resolveLimit(req: IncomingMessage): number | undefined {
if (raw == null || raw.trim() === "") {
return undefined;
}
const value = Number.parseInt(raw, 10);
if (!Number.isFinite(value) || value < 1) {
const trimmed = raw.trim();
const value = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
if (!Number.isSafeInteger(value) || value < 1) {
return 1;
}
return Math.min(MAX_SESSION_HISTORY_LIMIT, Math.max(1, value));

View File

@@ -337,18 +337,20 @@ export const streamOpenAICodexResponses: StreamFunction<
const retryAfterMs = response.headers.get("retry-after-ms");
if (retryAfterMs !== null) {
const millis = Number(retryAfterMs);
if (Number.isFinite(millis)) {
const trimmedRetryAfterMs = retryAfterMs.trim();
const millis = Number(trimmedRetryAfterMs);
if (/^\d+(?:\.\d+)?$/.test(trimmedRetryAfterMs) && Number.isFinite(millis)) {
delayMs = Math.max(0, millis);
}
} else {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) {
const trimmedRetryAfter = retryAfter.trim();
const seconds = Number(trimmedRetryAfter);
if (/^\d+(?:\.\d+)?$/.test(trimmedRetryAfter) && Number.isFinite(seconds)) {
delayMs = Math.max(0, seconds * 1000);
} else {
const date = Date.parse(retryAfter);
const date = Date.parse(trimmedRetryAfter);
if (!Number.isNaN(date)) {
delayMs = Math.max(0, date - Date.now());
}

View File

@@ -152,8 +152,9 @@ function parseCopilotTokenResponse(value: unknown): {
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt;
} else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) {
const parsed = Number.parseInt(expiresAt, 10);
if (!Number.isFinite(parsed)) {
const trimmed = expiresAt.trim();
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
if (!Number.isSafeInteger(parsed)) {
throw new Error("Copilot token response has invalid expires_at");
}
expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed;