mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 11:48:32 +00:00
fix: reject partial numeric runtime values
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user