fix(minimax): normalize OAuth token expiry to absolute millisecond timestamp (#83480)

* fix(minimax): normalize OAuth token expiry to absolute millisecond timestamp

MiniMax returns expired_in from the token endpoint as a relative duration
in seconds (standard OAuth expires_in semantics), but the auth profile
store's hasUsableOAuthCredential() expects an absolute millisecond
timestamp.  Without conversion the token appears perpetually expired,
triggering a slow OAuth refresh network call to api.minimaxi.com on
every request — the root cause of the 30-50s auth-stage delay.

Fixes #83449.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(minimax): cover oauth expiry normalization

* fix: polish minimax oauth expiry normalization (#83480) (thanks @NianJiuZst)

* fix: update minimax raw fetch allowlist (#83480)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
NianJiu
2026-05-24 12:21:22 +08:00
committed by GitHub
parent 55f994a8d0
commit d4e42d61c9
4 changed files with 38 additions and 4 deletions

View File

@@ -65,6 +65,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- MiniMax: store OAuth token expiry as an absolute millisecond timestamp so OAuth profiles no longer appear expired on every request. (#83480) Thanks @NianJiuZst.
- Gateway/restart: honor the configured restart drain budget for embedded runs and avoid spending the deferral timeout twice after forced restart timeouts. (#85708) Thanks @Kaspre.
- Gateway/boot: run `BOOT.md` startup checks in an isolated boot session so gateway restarts do not overwrite the agent's main session mapping. (#85479)
- Meeting Notes: include a speaker-labeled transcript section in generated summaries so Discord group voice captures show who said each captured utterance.

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { normalizeOAuthExpires } from "./oauth.js";
describe("normalizeOAuthExpires", () => {
it("converts relative expiry seconds into an absolute millisecond timestamp", () => {
expect(normalizeOAuthExpires(86_400, 1_700_000_000_000)).toBe(1_700_086_400_000);
});
it("converts Unix second timestamps into milliseconds", () => {
expect(normalizeOAuthExpires(1_700_000_000)).toBe(1_700_000_000_000);
});
it("preserves absolute millisecond timestamps", () => {
expect(normalizeOAuthExpires(1_700_000_000_000)).toBe(1_700_000_000_000);
});
});

View File

@@ -17,6 +17,8 @@ const MINIMAX_OAUTH_CONFIG = {
const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion";
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;
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
@@ -51,6 +53,20 @@ type TokenResult =
| TokenPending
| { status: "error"; message: string };
/**
* Normalize MiniMax token endpoint `expired_in` values to the auth-profile
* contract: absolute Unix milliseconds.
*/
export function normalizeOAuthExpires(expiredIn: number, now = Date.now()): number {
if (expiredIn < MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD) {
return now + expiredIn * 1000;
}
if (expiredIn < MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD) {
return expiredIn * 1000;
}
return expiredIn;
}
function generatePkce(): { verifier: string; challenge: string; state: string } {
const { verifier, challenge } = generatePkceVerifierChallenge();
const state = randomBytes(16).toString("base64url");
@@ -172,7 +188,7 @@ async function pollOAuthToken(params: {
token: {
access: tokenPayload.access_token,
refresh: tokenPayload.refresh_token,
expires: tokenPayload.expired_in,
expires: normalizeOAuthExpires(tokenPayload.expired_in),
resourceUrl: tokenPayload.resource_url,
notification_message: tokenPayload.notification_message,
},
@@ -196,7 +212,7 @@ export async function loginMiniMaxPortalOAuth(params: {
const noteLines = [
`Open ${verificationUrl} to approve access.`,
`If prompted, enter the code ${oauth.user_code}.`,
`Interval: ${oauth.interval ?? "default (2000ms)"}, Expires at: ${oauth.expired_in} unix timestamp`,
`Interval: ${oauth.interval ?? "default (2000ms)"}, Expires at: ${new Date(oauth.expired_in).toISOString()}`,
];
await params.note(noteLines.join("\n"), "MiniMax OAuth");
@@ -207,6 +223,7 @@ export async function loginMiniMaxPortalOAuth(params: {
}
let pollIntervalMs = oauth.interval ? oauth.interval : 2000;
// The authorization endpoint returns an absolute millisecond deadline.
const expireTimeMs = oauth.expired_in;
while (Date.now() < expireTimeMs) {

View File

@@ -33,8 +33,8 @@ const allowedRawFetchCallsites = new Set([
bundledPluginCallsite("matrix", "src/matrix/sdk/transport.ts", 112),
bundledPluginCallsite("microsoft-foundry", "onboard.ts", 479),
bundledPluginCallsite("microsoft", "speech-provider.ts", 140),
bundledPluginCallsite("minimax", "oauth.ts", 66),
bundledPluginCallsite("minimax", "oauth.ts", 107),
bundledPluginCallsite("minimax", "oauth.ts", 82),
bundledPluginCallsite("minimax", "oauth.ts", 123),
bundledPluginCallsite("minimax", "tts.ts", 52),
bundledPluginCallsite("msteams", "src/graph.ts", 47),
bundledPluginCallsite("msteams", "src/sdk.ts", 400),