Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
574
extensions/xai/xai-oauth.test.ts
Normal file
574
extensions/xai/xai-oauth.test.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
// Xai tests cover xai oauth plugin behavior.
|
||||
import type { ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
createRuntimeEnv,
|
||||
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 {
|
||||
createXaiDeviceCodeAuthMethod,
|
||||
createXaiOAuthAuthMethod,
|
||||
fetchXaiOAuthDiscovery,
|
||||
isTrustedXaiOAuthEndpoint,
|
||||
loginXaiDeviceCode,
|
||||
refreshXaiOAuthCredential,
|
||||
XAI_OAUTH_CLIENT_ID,
|
||||
XAI_OAUTH_DISCOVERY_URL,
|
||||
XAI_OAUTH_SCOPE,
|
||||
} from "./xai-oauth.js";
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
function requireStringBody(init: RequestInit | undefined): string {
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("expected request body to be a 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;
|
||||
}
|
||||
|
||||
describe("xAI OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts only trusted xAI OAuth endpoints", () => {
|
||||
expect(isTrustedXaiOAuthEndpoint("https://auth.x.ai/oauth2/token")).toBe(true);
|
||||
expect(isTrustedXaiOAuthEndpoint("https://accounts.x.ai/oauth2/token")).toBe(true);
|
||||
expect(isTrustedXaiOAuthEndpoint("http://auth.x.ai/oauth2/token")).toBe(false);
|
||||
expect(isTrustedXaiOAuthEndpoint("https://x.ai.evil.test/oauth2/token")).toBe(false);
|
||||
expect(isTrustedXaiOAuthEndpoint("not a url")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the public auth method named OAuth while using device code", () => {
|
||||
const method = createXaiOAuthAuthMethod();
|
||||
|
||||
expect(method.id).toBe("oauth");
|
||||
expect(method.kind).toBe("oauth");
|
||||
expect(method.wizard?.choiceId).toBe("xai-oauth");
|
||||
expect(method.wizard?.methodId).toBe("oauth");
|
||||
});
|
||||
|
||||
it("preserves device-code as an explicit auth method alias", () => {
|
||||
const method = createXaiDeviceCodeAuthMethod();
|
||||
|
||||
expect(method.id).toBe("device-code");
|
||||
expect(method.kind).toBe("device_code");
|
||||
expect(method.wizard?.choiceId).toBe("xai-device-code");
|
||||
expect(method.wizard?.methodId).toBe("device-code");
|
||||
expect(method.wizard?.assistantVisibility).toBe("manual-only");
|
||||
});
|
||||
|
||||
it("validates discovered endpoints before using them", async () => {
|
||||
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
||||
token_endpoint: "https://auth.x.ai/oauth2/token",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchXaiOAuthDiscovery({ fetchImpl })).resolves.toEqual({
|
||||
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
||||
});
|
||||
|
||||
const discoveryInit = fetchImpl.mock.calls.at(0)?.[1];
|
||||
const discoveryHeaders = new Headers(discoveryInit?.headers ?? {});
|
||||
expect(discoveryHeaders.get("user-agent")).toBe("openclaw/2026.3.22");
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
const poisonedFetch = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
||||
token_endpoint: "https://evil.test/oauth2/token",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchXaiOAuthDiscovery({ fetchImpl: poisonedFetch })).rejects.toThrow(
|
||||
"untrusted token endpoint",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes with the cached token endpoint and preserves refresh fallback", async () => {
|
||||
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(typeof init?.body).toBe("string");
|
||||
const body = requireStringBody(init);
|
||||
expect(body).toContain("grant_type=refresh_token");
|
||||
expect(body).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`);
|
||||
expect(body).toContain("refresh_token=refresh-1");
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
expect(headers.get("user-agent")).toBe("openclaw/2026.3.22");
|
||||
return jsonResponse({
|
||||
access_token: "access-2",
|
||||
expires_in: 120,
|
||||
});
|
||||
});
|
||||
|
||||
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(fetchImpl).toHaveBeenCalledWith("https://auth.x.ai/oauth2/token", expect.any(Object));
|
||||
expect(refreshed.access).toBe("access-2");
|
||||
expect(refreshed.refresh).toBe("refresh-1");
|
||||
expect(refreshed.expires).toBe(121_000);
|
||||
});
|
||||
|
||||
it("rediscovers the current token endpoint for stale xAI OAuth credentials", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
|
||||
if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) {
|
||||
expect(init?.method).toBeUndefined();
|
||||
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("refresh_token=refresh-1");
|
||||
return jsonResponse({
|
||||
access_token: "access-2",
|
||||
refresh_token: "refresh-2",
|
||||
expires_in: 120,
|
||||
});
|
||||
});
|
||||
const credential = {
|
||||
type: "oauth",
|
||||
provider: "xai",
|
||||
access: "access-1",
|
||||
refresh: "refresh-1",
|
||||
expires: 100,
|
||||
tokenEndpoint: "https://auth.x.ai/oauth/token",
|
||||
} satisfies OAuthCredential & { tokenEndpoint: string };
|
||||
|
||||
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
||||
expect(fetchImpl.mock.calls.map(([url]) => requestUrl(url))).toEqual([
|
||||
XAI_OAUTH_DISCOVERY_URL,
|
||||
"https://auth.x.ai/oauth2/token",
|
||||
]);
|
||||
expect(refreshed).toMatchObject({
|
||||
access: "access-2",
|
||||
refresh: "refresh-2",
|
||||
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse the stale xAI OAuth token endpoint when discovery fails", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async (url) => {
|
||||
expect(requestUrl(url)).toBe(XAI_OAUTH_DISCOVERY_URL);
|
||||
throw new Error("discovery unavailable");
|
||||
});
|
||||
const credential = {
|
||||
type: "oauth",
|
||||
provider: "xai",
|
||||
access: "access-1",
|
||||
refresh: "refresh-1",
|
||||
expires: 100,
|
||||
tokenEndpoint: "https://auth.x.ai/oauth/token",
|
||||
} satisfies OAuthCredential & { tokenEndpoint: string };
|
||||
|
||||
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
||||
"discovery unavailable",
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries transient HTML refresh failures before succeeding", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("<!DOCTYPE html><html><body>Attention Required! Cloudflare</body></html>", {
|
||||
status: 403,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"cf-mitigated": "challenge",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response("<!DOCTYPE html><html><body>Just a moment...</body></html>", {
|
||||
status: 403,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
access_token: "access-2",
|
||||
expires_in: 120,
|
||||
}),
|
||||
);
|
||||
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 refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
const refreshed = await refresh;
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
expect(refreshed.access).toBe("access-2");
|
||||
expect(refreshed.refresh).toBe("refresh-1");
|
||||
});
|
||||
|
||||
it("surfaces xAI Cloudflare refresh failures after retry exhaustion", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchImpl = vi.fn<typeof fetch>(
|
||||
async () =>
|
||||
new Response(
|
||||
"<!DOCTYPE html><html><head><title>Attention Required! | Cloudflare</title></head><body>You are unable to access x.ai</body></html>",
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"cf-mitigated": "challenge",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
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 refresh = refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
|
||||
const expectation = expect(refresh).rejects.toThrow(
|
||||
"xAI returned an HTML/Cloudflare challenge",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
await expectation;
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not retry terminal xAI OAuth refresh errors", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "Invalid or unknown refresh token",
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
);
|
||||
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 };
|
||||
|
||||
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
||||
"invalid_grant (Invalid or unknown refresh token)",
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry refresh-token service failures", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse(
|
||||
{
|
||||
error: "server_error",
|
||||
error_description: "try again later",
|
||||
},
|
||||
{ status: 503 },
|
||||
),
|
||||
);
|
||||
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 };
|
||||
|
||||
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
||||
"server_error (try again later)",
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry refresh on transport errors so a rotated refresh token is never resent", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
||||
throw new Error("socket hang up");
|
||||
});
|
||||
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 };
|
||||
|
||||
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
|
||||
"socket hang up",
|
||||
);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not coerce partial xAI expires_in values", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(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("preserves the cached xAI expiry when token lifetimes overflow safe milliseconds", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse({
|
||||
access_token: createJwt({ exp: Number.MAX_SAFE_INTEGER }),
|
||||
expires_in: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
);
|
||||
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("ignores unsafe JWT expiry fallbacks from xAI access tokens", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse({
|
||||
access_token: createJwt({ exp: Number.MAX_SAFE_INTEGER }),
|
||||
}),
|
||||
);
|
||||
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("logs in with xAI device code without a localhost callback", async () => {
|
||||
vi.stubEnv("OPENCLAW_VERSION", "2026.3.22");
|
||||
const progress = {
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
||||
device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code",
|
||||
token_endpoint: "https://auth.x.ai/oauth2/token",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
device_code: "device-code-1",
|
||||
user_code: "ABCD-1234",
|
||||
verification_uri: "https://accounts.x.ai/oauth2/device",
|
||||
verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-1234",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
access_token: createJwt({ exp: 4, sub: "acct-1" }),
|
||||
refresh_token: "refresh-1",
|
||||
id_token: createJwt({
|
||||
sub: "acct-1",
|
||||
email: "dev@example.com",
|
||||
name: "Dev User",
|
||||
}),
|
||||
expires_in: 120,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => {});
|
||||
const openUrl = vi.fn(async () => {});
|
||||
const log = vi.fn();
|
||||
const runtime = { ...createRuntimeEnv(), log };
|
||||
const ctx: ProviderAuthContext = {
|
||||
config: {},
|
||||
isRemote: true,
|
||||
openUrl,
|
||||
prompter: createTestWizardPrompter({
|
||||
progress: vi.fn(() => progress),
|
||||
note,
|
||||
}),
|
||||
runtime,
|
||||
oauth: {
|
||||
createVpsAwareHandlers: () => {
|
||||
throw new Error("unexpected VPS OAuth handler request");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await loginXaiDeviceCode(ctx);
|
||||
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
expect(note).toHaveBeenCalledWith(expect.stringContaining("ABCD-1234"), "xAI OAuth");
|
||||
const remoteLog = log.mock.calls[0]?.[0];
|
||||
expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device");
|
||||
expect(remoteLog).not.toContain("ABCD-1234");
|
||||
const deviceRequest = fetchImpl.mock.calls[1]?.[1];
|
||||
expect(deviceRequest?.method).toBe("POST");
|
||||
const deviceBody = requireStringBody(deviceRequest);
|
||||
expect(deviceBody).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`);
|
||||
expect(deviceBody).toContain(`scope=${encodeURIComponent(XAI_OAUTH_SCOPE)}`);
|
||||
|
||||
const tokenRequest = fetchImpl.mock.calls[2]?.[1];
|
||||
expect(tokenRequest?.method).toBe("POST");
|
||||
const tokenBody = requireStringBody(tokenRequest);
|
||||
expect(tokenBody).toContain(
|
||||
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
|
||||
);
|
||||
expect(tokenBody).toContain("device_code=device-code-1");
|
||||
|
||||
expect(result.profiles[0]?.credential).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "xai",
|
||||
refresh: "refresh-1",
|
||||
email: "dev@example.com",
|
||||
displayName: "Dev User",
|
||||
tokenEndpoint: "https://auth.x.ai/oauth2/token",
|
||||
deviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code",
|
||||
issuer: "https://auth.x.ai",
|
||||
authFlow: "device-code",
|
||||
accountId: "acct-1",
|
||||
access: expect.any(String),
|
||||
});
|
||||
expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization...");
|
||||
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
|
||||
});
|
||||
|
||||
it("falls back for unsafe xAI device-code lifetime fields", async () => {
|
||||
const progress = {
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const fetchImpl = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
|
||||
device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code",
|
||||
token_endpoint: "https://auth.x.ai/oauth2/token",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
device_code: "device-code-1",
|
||||
user_code: "ABCD-1234",
|
||||
verification_uri: "https://accounts.x.ai/oauth2/device",
|
||||
expires_in: Number.MAX_SAFE_INTEGER,
|
||||
interval: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-1",
|
||||
expires_in: 120,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => {});
|
||||
const ctx: ProviderAuthContext = {
|
||||
config: {},
|
||||
isRemote: true,
|
||||
openUrl: vi.fn(async () => {}),
|
||||
prompter: createTestWizardPrompter({
|
||||
progress: vi.fn(() => progress),
|
||||
note,
|
||||
}),
|
||||
runtime: createRuntimeEnv(),
|
||||
oauth: {
|
||||
createVpsAwareHandlers: () => {
|
||||
throw new Error("unexpected VPS OAuth handler request");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await loginXaiDeviceCode(ctx);
|
||||
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Code expires in 5 minutes."),
|
||||
"xAI OAuth",
|
||||
);
|
||||
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user