From 159b3002e4d3eb8903d1ca8e7c3ef20ae3bbe075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Cuevas?= Date: Thu, 21 May 2026 10:08:34 -0400 Subject: [PATCH] fix(xai): keep OAuth URL clickable (#84927) --- extensions/xai/xai-oauth.test.ts | 127 ++++++++++++++++++++++++++++++- extensions/xai/xai-oauth.ts | 7 +- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index e3850ca8e1f..5df252bd691 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -4,17 +4,27 @@ import { createTestWizardPrompter, } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const waitForLocalOAuthCallbackMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({ + waitForLocalOAuthCallback: waitForLocalOAuthCallbackMock, +})); + import { buildXaiOAuthAuthorizationCodeTokenBody, buildXaiOAuthAuthorizeUrl, fetchXaiOAuthDiscovery, isTrustedXaiOAuthEndpoint, loginXaiDeviceCode, + loginXaiOAuth, refreshXaiOAuthCredential, XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST, + XAI_OAUTH_CALLBACK_HOST, XAI_OAUTH_CALLBACK_PORT, XAI_OAUTH_CLIENT_ID, + XAI_OAUTH_DISCOVERY_URL, XAI_OAUTH_REDIRECT_URI, XAI_OAUTH_SCOPE, } from "./xai-oauth.js"; @@ -40,7 +50,42 @@ function requireStringBody(init: RequestInit | undefined): string { return init.body; } +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === "string") { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input.url; +} + +function stubSuccessfulXaiOAuthNetwork(): void { + const fetchImpl = vi.fn(async (url, init) => { + if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) { + return jsonResponse({ + authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + token_endpoint: "https://auth.x.ai/oauth2/token", + }); + } + + expect(requestUrl(url)).toBe("https://auth.x.ai/oauth2/token"); + expect(init?.method).toBe("POST"); + expect(requireStringBody(init)).toContain("code=AUTHCODE"); + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + expires_in: 3600, + }); + }); + vi.stubGlobal("fetch", fetchImpl); +} + describe("xAI OAuth", () => { + beforeEach(() => { + waitForLocalOAuthCallbackMock.mockReset(); + }); + afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); @@ -163,7 +208,85 @@ describe("xAI OAuth", () => { expect(refreshed.access).toBe("access-2"); expect(refreshed.refresh).toBe("refresh-1"); expect(refreshed.expires).toBe(121_000); - vi.unstubAllEnvs(); + }); + + 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(); + + const progress = { update: vi.fn(), stop: vi.fn() }; + const note = vi.fn<(message: string, title?: string) => Promise>(async () => undefined); + const plain = vi.fn<(message: string) => Promise>(async () => undefined); + const openUrl = vi.fn<(url: string) => Promise>(async () => undefined); + const runtimeLog = vi.fn<(message: string) => void>(); + const ctx = { + config: {}, + isRemote: true, + openUrl, + prompter: { + note, + plain, + progress: vi.fn(() => progress), + }, + runtime: { + log: runtimeLog, + error: vi.fn(), + exit: vi.fn(), + }, + oauth: { createVpsAwareHandlers: vi.fn() }, + } as unknown as ProviderAuthContext; + + await loginXaiOAuth(ctx); + + expect(openUrl).not.toHaveBeenCalled(); + const noteMessage = note.mock.calls[0]?.[0] ?? ""; + expect(noteMessage).toContain("Open this xAI OAuth URL in your browser:"); + expect(noteMessage).toContain( + `ssh -N -L ${XAI_OAUTH_CALLBACK_PORT}:${XAI_OAUTH_CALLBACK_HOST}:${XAI_OAUTH_CALLBACK_PORT} `, + ); + expect(noteMessage).not.toContain("https://auth.x.ai/oauth2/authorize"); + + const plainOutput = plain.mock.calls[0]?.[0] ?? ""; + expect(plainOutput.trim()).toMatch(/^https:\/\/auth\.x\.ai\/oauth2\/authorize\?/); + expect(plainOutput).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`); + expect(plainOutput).toContain("code_challenge="); + expect(runtimeLog).not.toHaveBeenCalled(); + expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); + }); + + it("keeps the authorize URL visible for prompters without plain output", async () => { + waitForLocalOAuthCallbackMock.mockResolvedValue({ code: "AUTHCODE", state: "state-1" }); + stubSuccessfulXaiOAuthNetwork(); + + const progress = { update: vi.fn(), stop: vi.fn() }; + const note = vi.fn<(message: string, title?: string) => Promise>(async () => undefined); + const openUrl = vi.fn<(url: string) => Promise>(async () => undefined); + const runtimeLog = vi.fn<(message: string) => void>(); + const ctx = { + config: {}, + isRemote: false, + openUrl, + prompter: { + note, + progress: vi.fn(() => progress), + }, + runtime: { + log: runtimeLog, + error: vi.fn(), + exit: vi.fn(), + }, + oauth: { createVpsAwareHandlers: vi.fn() }, + } as unknown as ProviderAuthContext; + + await loginXaiOAuth(ctx); + + const authorizeUrl = openUrl.mock.calls[0]?.[0] ?? ""; + const noteMessage = note.mock.calls[0]?.[0] ?? ""; + expect(authorizeUrl).toContain("https://auth.x.ai/oauth2/authorize?"); + expect(noteMessage).toContain("Open this xAI OAuth URL in your browser:"); + expect(noteMessage).not.toContain(authorizeUrl); + expect(runtimeLog.mock.calls[0]?.[0] ?? "").toContain(authorizeUrl); + expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete"); }); it("logs in with xAI device code without a localhost callback", async () => { diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index bea4fc6e862..0d065a0a871 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -511,7 +511,7 @@ function readCredentialString( } async function noteXaiOAuthUrl(ctx: ProviderAuthContext, authorizeUrl: string): Promise { - const lines = ["Open this xAI OAuth URL in your browser:", authorizeUrl]; + const lines = ["Open this xAI OAuth URL in your browser:"]; if (ctx.isRemote) { lines.push( "", @@ -520,6 +520,11 @@ async function noteXaiOAuthUrl(ctx: ProviderAuthContext, authorizeUrl: string): ); } await ctx.prompter.note(lines.join("\n"), "xAI OAuth"); + if (ctx.prompter.plain) { + await ctx.prompter.plain(`\n${authorizeUrl}\n`); + return; + } + ctx.runtime.log(`\n${authorizeUrl}\n`); } export async function loginXaiOAuth(ctx: ProviderAuthContext): Promise {