From 8f8aad9ae69253ecf91526a48f97dcb8d2d43acf Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Sat, 11 Jul 2026 13:02:34 +0800
Subject: [PATCH] fix(minimax): add timeouts to OAuth HTTP requests (#102862)
* fix(minimax): add timeouts to OAuth HTTP requests
* fix(minimax): remove duplicate OAuth abort signals
* refactor(minimax): keep OAuth timeout internal
* test(minimax): make OAuth timeout proof deterministic
---------
Co-authored-by: llagy009 <0668001470@xydigit.com>
Co-authored-by: Peter Steinberger
---
extensions/minimax/oauth.test.ts | 296 +++++++++++++++++++++++++++++++
extensions/minimax/oauth.ts | 3 +
2 files changed, 299 insertions(+)
diff --git a/extensions/minimax/oauth.test.ts b/extensions/minimax/oauth.test.ts
index a9a1598e1c53..d8f5f0cc628e 100644
--- a/extensions/minimax/oauth.test.ts
+++ b/extensions/minimax/oauth.test.ts
@@ -1,8 +1,12 @@
// Minimax tests cover oauth plugin behavior.
+import { createServer } from "node:http";
+import type { Socket } from "node:net";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.js";
+const MINIMAX_OAUTH_FETCH_TIMEOUT_MS = 30_000;
+
function cancelTrackedResponse(
text: string,
init: ResponseInit,
@@ -25,6 +29,190 @@ function cancelTrackedResponse(
};
}
+function timeoutResult(value: T, timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve(value), timeoutMs);
+ });
+}
+
+function captureMiniMaxOAuthFetchTimeout() {
+ const originalSetTimeout = globalThis.setTimeout;
+ let fireTimeout: (() => void) | undefined;
+ const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(((
+ callback: (...args: unknown[]) => void,
+ timeout?: number,
+ ...args: unknown[]
+ ) => {
+ if (timeout === MINIMAX_OAUTH_FETCH_TIMEOUT_MS) {
+ fireTimeout = () => callback(...args);
+ return 0 as unknown as ReturnType;
+ }
+ return originalSetTimeout(() => callback(...args), timeout);
+ }) as typeof setTimeout);
+ return {
+ setTimeoutSpy,
+ fire() {
+ if (!fireTimeout) {
+ throw new Error("expected MiniMax OAuth fetch timeout to be scheduled");
+ }
+ const callback = fireTimeout;
+ fireTimeout = undefined;
+ callback();
+ },
+ };
+}
+
+async function listenOnLoopback(server: ReturnType): Promise {
+ return await new Promise((resolve, reject) => {
+ const onError = (err: Error) => reject(err);
+ server.once("error", onError);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", onError);
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ reject(new Error("expected loopback TCP address"));
+ return;
+ }
+ resolve(address.port);
+ });
+ });
+}
+
+async function startHangingLoopbackServer(): Promise<{
+ origin: string;
+ requests: string[];
+ waitForRequestCount: (count: number) => Promise;
+ close: () => Promise;
+}> {
+ type RequestWaiter = {
+ count: number;
+ resolve: () => void;
+ reject: (error: Error) => void;
+ timer?: ReturnType;
+ };
+
+ const sockets = new Set();
+ const requests: string[] = [];
+ const waiters: RequestWaiter[] = [];
+
+ const resolveWaiters = () => {
+ for (let index = waiters.length - 1; index >= 0; index -= 1) {
+ const waiter = waiters[index];
+ if (!waiter || requests.length < waiter.count) {
+ continue;
+ }
+ waiters.splice(index, 1);
+ if (waiter.timer) {
+ clearTimeout(waiter.timer);
+ }
+ waiter.resolve();
+ }
+ };
+
+ const server = createServer((req, _res) => {
+ requests.push(req.url ?? "");
+ req.resume();
+ resolveWaiters();
+ });
+ server.on("connection", (socket) => {
+ sockets.add(socket);
+ socket.on("close", () => sockets.delete(socket));
+ });
+
+ const port = await listenOnLoopback(server);
+ return {
+ origin: `http://127.0.0.1:${port}`,
+ requests,
+ waitForRequestCount: async (count: number) => {
+ if (requests.length >= count) {
+ return;
+ }
+ await new Promise((resolve, reject) => {
+ const waiter: RequestWaiter = {
+ count,
+ resolve,
+ reject,
+ };
+ waiter.timer = setTimeout(() => {
+ const index = waiters.indexOf(waiter);
+ if (index >= 0) {
+ waiters.splice(index, 1);
+ }
+ reject(new Error(`server received ${requests.length} request(s), expected ${count}`));
+ }, 2_000);
+ waiters.push(waiter);
+ });
+ },
+ close: async () => {
+ for (const waiter of waiters.splice(0)) {
+ if (waiter.timer) {
+ clearTimeout(waiter.timer);
+ }
+ waiter.reject(new Error("server closed"));
+ }
+ for (const socket of sockets) {
+ socket.destroy();
+ }
+ await new Promise((resolve, reject) => {
+ server.close((err) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+ resolve();
+ });
+ });
+ },
+ };
+}
+
+async function expectFetchWithoutDeadlineToStayPending(params: {
+ url: string;
+ init?: RequestInit;
+ waitForRequest: () => Promise;
+}) {
+ const controller = new AbortController();
+ const request = fetch(params.url, { ...params.init, signal: controller.signal });
+ request.catch(() => undefined);
+ await params.waitForRequest();
+
+ const result = await Promise.race([
+ request.then(
+ () => "settled" as const,
+ () => "settled" as const,
+ ),
+ timeoutResult("pending" as const, 30),
+ ]);
+
+ controller.abort();
+ await request.catch(() => undefined);
+ expect(result).toBe("pending");
+}
+
+async function loginOutcomeWithin(
+ promise: Promise,
+ timeoutMs: number,
+): Promise<
+ | { status: "pending" }
+ | { status: "resolved" }
+ | {
+ status: "rejected";
+ error: unknown;
+ }
+> {
+ return await Promise.race([
+ promise.then(
+ () => ({ status: "resolved" as const }),
+ (error: unknown) => ({ status: "rejected" as const, error }),
+ ),
+ timeoutResult({ status: "pending" as const }, timeoutMs),
+ ]);
+}
+
+function expectAbortOrTimeoutError(error: unknown) {
+ expect(error).toHaveProperty("name", expect.stringMatching(/^(AbortError|TimeoutError)$/));
+}
+
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -52,6 +240,114 @@ describe("normalizeOAuthExpires", () => {
});
describe("loginMiniMaxPortalOAuth", () => {
+ it("times out authorization code HTTP requests against a hanging loopback server", async () => {
+ const realFetch = fetch;
+ const server = await startHangingLoopbackServer();
+ const oauthTimeout = captureMiniMaxOAuthFetchTimeout();
+ let loginPromise: Promise | undefined;
+
+ try {
+ await expectFetchWithoutDeadlineToStayPending({
+ url: `${server.origin}/control`,
+ init: { method: "POST", body: "response_type=code" },
+ waitForRequest: () => server.waitForRequestCount(1),
+ });
+
+ const fetchMock = vi.fn(
+ async (_input: RequestInfo | URL, init?: RequestInit) =>
+ await realFetch(`${server.origin}/device-code`, init),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ loginPromise = loginMiniMaxPortalOAuth({
+ openUrl: vi.fn(async () => undefined),
+ note: vi.fn(async () => undefined),
+ progress: { update: vi.fn(), stop: vi.fn() },
+ });
+ loginPromise.catch(() => undefined);
+
+ await server.waitForRequestCount(2);
+ oauthTimeout.fire();
+ const result = await loginOutcomeWithin(loginPromise, 2_000);
+ if (result.status !== "rejected") {
+ throw new Error(`expected authorization code request to reject, got ${result.status}`);
+ }
+ expectAbortOrTimeoutError(result.error);
+ expect(server.requests).toContain("/device-code");
+ expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal);
+ expect(
+ oauthTimeout.setTimeoutSpy.mock.calls.some(
+ ([, timeout]) => timeout === MINIMAX_OAUTH_FETCH_TIMEOUT_MS,
+ ),
+ ).toBe(true);
+ } finally {
+ await server.close();
+ await loginPromise?.catch(() => undefined);
+ }
+ });
+
+ it("times out token polling HTTP requests against a hanging loopback server", async () => {
+ const realFetch = fetch;
+ const server = await startHangingLoopbackServer();
+ const oauthTimeout = captureMiniMaxOAuthFetchTimeout();
+ let loginPromise: Promise | undefined;
+
+ try {
+ await expectFetchWithoutDeadlineToStayPending({
+ url: `${server.origin}/control`,
+ init: { method: "POST", body: "grant_type=user_code" },
+ waitForRequest: () => server.waitForRequestCount(1),
+ });
+
+ let callCount = 0;
+ const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ callCount += 1;
+ const body =
+ init?.body instanceof URLSearchParams
+ ? init.body
+ : new URLSearchParams(typeof init?.body === "string" ? init.body : "");
+ if (callCount === 1) {
+ return new Response(
+ JSON.stringify({
+ user_code: "CODE",
+ verification_uri: "https://example.com/device",
+ expired_in: Date.now() + 10_000,
+ state: body.get("state"),
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ return await realFetch(`${server.origin}/token`, init);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ loginPromise = loginMiniMaxPortalOAuth({
+ openUrl: vi.fn(async () => undefined),
+ note: vi.fn(async () => undefined),
+ progress: { update: vi.fn(), stop: vi.fn() },
+ });
+ loginPromise.catch(() => undefined);
+
+ await server.waitForRequestCount(2);
+ oauthTimeout.fire();
+ const result = await loginOutcomeWithin(loginPromise, 2_000);
+ if (result.status !== "rejected") {
+ throw new Error(`expected token polling request to reject, got ${result.status}`);
+ }
+ expectAbortOrTimeoutError(result.error);
+ expect(server.requests).toContain("/token");
+ expect(fetchMock.mock.calls[1]?.[1]?.signal).toBeInstanceOf(AbortSignal);
+ expect(
+ oauthTimeout.setTimeoutSpy.mock.calls.some(
+ ([, timeout]) => timeout === MINIMAX_OAUTH_FETCH_TIMEOUT_MS,
+ ),
+ ).toBe(true);
+ } finally {
+ await server.close();
+ await loginPromise?.catch(() => undefined);
+ }
+ });
+
it("bounds authorization error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(
`${"minimax authorization unavailable ".repeat(1024)}tail`,
diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts
index 1494cb486214..f990d2f89273 100644
--- a/extensions/minimax/oauth.ts
+++ b/extensions/minimax/oauth.ts
@@ -34,6 +34,7 @@ const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code";
const MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD = 1_000_000_000;
const MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD = 1_000_000_000_000;
const MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
+const MINIMAX_OAUTH_FETCH_TIMEOUT_MS = 30_000;
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
@@ -115,6 +116,7 @@ async function requestOAuthCode(params: {
state: params.state,
}),
},
+ timeoutMs: MINIMAX_OAUTH_FETCH_TIMEOUT_MS,
policy: { allowedHostnames: [endpoints.hostname] },
auditContext: "minimax.oauth.code",
});
@@ -168,6 +170,7 @@ async function pollOAuthToken(params: {
code_verifier: params.verifier,
}),
},
+ timeoutMs: MINIMAX_OAUTH_FETCH_TIMEOUT_MS,
policy: { allowedHostnames: [endpoints.hostname] },
auditContext: "minimax.oauth.token",
});