diff --git a/extensions/discord/src/delivery-retry.ts b/extensions/discord/src/delivery-retry.ts index 9301bb6fb77..bc2d817f17f 100644 --- a/extensions/discord/src/delivery-retry.ts +++ b/extensions/discord/src/delivery-retry.ts @@ -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; } diff --git a/extensions/msteams/src/polls.test.ts b/extensions/msteams/src/polls.test.ts index 6dc9a05e649..5cb5b767620 100644 --- a/extensions/msteams/src/polls.test.ts +++ b/extensions/msteams/src/polls.test.ts @@ -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 () => { diff --git a/extensions/msteams/src/polls.ts b/extensions/msteams/src/polls.ts index 8e593f9aa64..bc16cc23a80 100644 --- a/extensions/msteams/src/polls.ts +++ b/extensions/msteams/src/polls.ts @@ -253,8 +253,11 @@ function pruneToLimit(polls: Record) { 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); diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index fafa102c234..4010b36e211 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -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", diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 442f20886bf..cab172d863d 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -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")), }; } diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index 5df252bd691..ffbc19b5ef4 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -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(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(); diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index 0d065a0a871..2c3acf8b364 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -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, diff --git a/src/agents/openai-responses-payload-policy.test.ts b/src/agents/openai-responses-payload-policy.test.ts index 4df11e8b4a0..7141f6a5076 100644 --- a/src/agents/openai-responses-payload-policy.test.ts +++ b/src/agents/openai-responses-payload-policy.test.ts @@ -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; + + 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( { diff --git a/src/agents/openai-responses-payload-policy.ts b/src/agents/openai-responses-payload-policy.ts index d9f1d39bbce..2591f78c48e 100644 --- a/src/agents/openai-responses-payload-policy.ts +++ b/src/agents/openai-responses-payload-policy.ts @@ -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; } } diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 38a00f3af83..3577cc7ac31 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -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); }); diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 58691b5c68c..b106e184b9d 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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 { diff --git a/src/gateway/session-history-state.test.ts b/src/gateway/session-history-state.test.ts index abf997c0285..76fa6c54f83 100644 --- a/src/gateway/session-history-state.test.ts +++ b/src/gateway/session-history-state.test.ts @@ -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" }, diff --git a/src/gateway/session-history-state.ts b/src/gateway/session-history-state.ts index fe8a4536456..3b277da66a8 100644 --- a/src/gateway/session-history-state.ts +++ b/src/gateway/session-history-state.ts @@ -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[] { diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index ec353b359de..c06f38a11e6 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -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)); diff --git a/src/llm/providers/openai-codex-responses.ts b/src/llm/providers/openai-codex-responses.ts index 8abf7b878bd..fdbb0dbe979 100644 --- a/src/llm/providers/openai-codex-responses.ts +++ b/src/llm/providers/openai-codex-responses.ts @@ -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()); } diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index c47a6e2ee79..f0bd942f48b 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -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;