diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts index b984cdea642..165b7c13ee8 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.test.ts @@ -1,3 +1,4 @@ +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { acquireQaCredentialLease, @@ -320,6 +321,37 @@ describe("credential lease runtime", () => { expect(fetchUrl(fetchImpl)).toBe("http://127.0.0.1:3210/qa-credentials/v1/acquire"); }); + it("caps oversized convex HTTP timeouts before creating abort signals", async () => { + const timeoutController = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const fetchImpl = vi.fn().mockResolvedValueOnce( + jsonResponse({ + status: "ok", + credentialId: "cred-timeout", + leaseToken: "lease-timeout", + payload: { groupId: "-100123", driverToken: "driver", sutToken: "sut" }, + }), + ); + + await acquireQaCredentialLease({ + kind: "telegram", + source: "convex", + role: "maintainer", + env: { + OPENCLAW_QA_CONVEX_SITE_URL: "https://qa-cred.example.convex.site", + OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maintainer-secret", + OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS: String(Number.MAX_SAFE_INTEGER), + }, + fetchImpl, + resolveEnvPayload: () => ({ groupId: "-1", driverToken: "unused", sutToken: "unused" }), + parsePayload: (payload) => + payload as { groupId: string; driverToken: string; sutToken: string }, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS); + expect(fetchInit(fetchImpl).signal).toBe(timeoutController.signal); + }); + it("rejects unsafe endpoint prefix overrides", async () => { await expect( acquireQaCredentialLease({ diff --git a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts index 4d590b09414..5aa4060f248 100644 --- a/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts +++ b/extensions/qa-lab/src/live-transports/shared/credential-lease.runtime.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { z } from "zod"; import { isQaCredentialTruthyOptIn, @@ -258,6 +259,7 @@ async function postConvexBroker(params: { timeoutMs: number; url: string; }): Promise { + const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, DEFAULT_HTTP_TIMEOUT_MS); const response = await params.fetchImpl(params.url, { method: "POST", headers: { @@ -265,7 +267,7 @@ async function postConvexBroker(params: { "content-type": "application/json", }, body: JSON.stringify(params.body), - signal: AbortSignal.timeout(params.timeoutMs), + signal: AbortSignal.timeout(timeoutMs), }); const text = await response.text(); diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts index e64fd475230..2c3d02371a6 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.test.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS, @@ -7,13 +8,20 @@ import { import { testing } from "./telegram-live.runtime.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => - vi.fn(async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({ - response: await fetch(params.url, { - ...params.init, - signal: params.signal, + vi.fn( + async (params: { + url: string; + init?: RequestInit; + signal?: AbortSignal; + timeoutMs?: number; + }) => ({ + response: await fetch(params.url, { + ...params.init, + signal: params.signal ?? AbortSignal.timeout(params.timeoutMs ?? 0), + }), + release: async () => {}, }), - release: async () => {}, - })), + ), ); vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => { @@ -904,6 +912,34 @@ describe("telegram live qa runtime", () => { expect(signal?.aborted).toBe(true); }); + it("caps oversized Telegram API request deadlines", async () => { + const controller = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal); + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response(JSON.stringify({ ok: true, result: { id: 42 } }), { + status: 200, + headers: { + "content-type": "application/json", + }, + }), + ), + ); + + await expect( + testing.callTelegramApi("token", "getMe", undefined, Number.MAX_SAFE_INTEGER), + ).resolves.toEqual({ + id: 42, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS); + expect(fetchWithSsrFGuardMock.mock.calls.at(-1)?.[0]).toMatchObject({ + timeoutMs: MAX_TIMER_TIMEOUT_MS, + }); + }); + it("treats transient Telegram getUpdates network errors as recoverable", () => { expect(testing.isRecoverableTelegramQaPollError(new TypeError("fetch failed"))).toBe(true); expect(testing.isRecoverableTelegramQaPollError(new Error("socket hang up"))).toBe(true); @@ -912,6 +948,7 @@ describe("telegram live qa runtime", () => { new Error("The operation was aborted due to timeout"), ), ).toBe(true); + expect(testing.isRecoverableTelegramQaPollError(new Error("request timed out"))).toBe(true); expect(testing.isRecoverableTelegramQaPollError(new Error("AbortError"))).toBe(true); expect(testing.isRecoverableTelegramQaPollError(new Error("Bad Request: chat not found"))).toBe( false, diff --git a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts index b2181b6cfb4..d2f89140738 100644 --- a/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/telegram/telegram-live.runtime.ts @@ -5,7 +5,10 @@ import path from "node:path"; import { promisify } from "node:util"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; +import { + parseStrictPositiveInteger, + resolveTimerTimeoutMs, +} from "openclaw/plugin-sdk/number-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; @@ -773,6 +776,7 @@ async function callTelegramApi( body?: Record, timeoutMs = 15_000, ): Promise { + const requestTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 15_000); const { response, release } = await fetchWithSsrFGuard({ url: `https://api.telegram.org/bot${token}/${method}`, init: { @@ -782,7 +786,7 @@ async function callTelegramApi( }, body: JSON.stringify(body ?? {}), }, - signal: AbortSignal.timeout(timeoutMs), + timeoutMs: requestTimeoutMs, policy: { hostnameAllowlist: ["api.telegram.org"] }, auditContext: "qa-lab-telegram-live", }); @@ -805,6 +809,7 @@ function isRecoverableTelegramQaPollError(error: unknown): boolean { message.includes("fetch failed") || message.includes("aborted due to timeout") || message.includes("operation was aborted") || + message.includes("request timed out") || message.includes("aborterror") || message.includes("econnreset") || message.includes("etimedout") || diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts index db293af22c3..a40223c9749 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; +import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { addQaCredentialSet, diagnoseQaCredentialBroker, @@ -52,6 +53,10 @@ async function expectQaCredentialAdminError(promise: Promise, code: str } describe("qa credential admin runtime", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("adds a credential set through the admin endpoint", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse({ @@ -151,6 +156,30 @@ describe("qa credential admin runtime", () => { ); }); + it("caps oversized admin HTTP timeouts before creating abort signals", async () => { + const timeoutController = new AbortController(); + const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + jsonResponse({ + status: "ok", + count: 0, + credentials: [], + }), + ); + + await listQaCredentialSets({ + siteUrl: "https://first-schnauzer-821.convex.site", + env: { + OPENCLAW_QA_CONVEX_SECRET_MAINTAINER: "maint-secret", + OPENCLAW_QA_CREDENTIAL_HTTP_TIMEOUT_MS: String(Number.MAX_SAFE_INTEGER), + }, + fetchImpl, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS); + expect(requireFirstFetchInit(fetchImpl).signal).toBe(timeoutController.signal); + }); + it("rejects unsafe endpoint-prefix overrides", async () => { await expectQaCredentialAdminError( listQaCredentialSets({ diff --git a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts index 7833f20e37c..a7f4cf70450 100644 --- a/extensions/qa-lab/src/qa-credentials-admin.runtime.ts +++ b/extensions/qa-lab/src/qa-credentials-admin.runtime.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { z } from "zod"; import { joinQaCredentialEndpoint, @@ -371,6 +372,7 @@ async function postJson(params: { responseSchema: z.ZodType; url: string; }) { + const httpTimeoutMs = resolveTimerTimeoutMs(params.httpTimeoutMs, DEFAULT_HTTP_TIMEOUT_MS); let response: Response; try { response = await params.fetchImpl(params.url, { @@ -380,7 +382,7 @@ async function postJson(params: { "content-type": "application/json", }, body: JSON.stringify(params.body), - signal: AbortSignal.timeout(params.httpTimeoutMs), + signal: AbortSignal.timeout(httpTimeoutMs), }); } catch (error) { throw new QaCredentialAdminError({