diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index 73612d8528..485a37fe00 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -118,7 +118,10 @@ function authHeaders() { // to make trust decisions, so unsigned decode is safe. Returns false for // opaque tokens (no JWT shape), which conservatively skips the proactive // refresh and lets the 401-on-call path drive the refresh instead. -export function accessTokenIsExpiring(token: string | undefined, skewMs: number = ACCESS_TOKEN_REFRESH_SKEW_MS): boolean { +export function accessTokenIsExpiring( + token: string | undefined, + skewMs: number = ACCESS_TOKEN_REFRESH_SKEW_MS, +): boolean { if (!token || typeof token !== "string") return false const parts = token.split(".") if (parts.length < 2) return false diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts index 741f29e86b..35ff9075ae 100644 --- a/packages/opencode/test/plugin/xai.test.ts +++ b/packages/opencode/test/plugin/xai.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test" -import { accessTokenIsExpiring, buildAuthorizeUrl, escapeHtml, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin } from "../../src/plugin/xai" +import { + accessTokenIsExpiring, + buildAuthorizeUrl, + escapeHtml, + pollDeviceCodeToken, + requestDeviceCode, + XaiAuthPlugin, +} from "../../src/plugin/xai" import { OAUTH_DUMMY_KEY } from "../../src/auth" function makeJwt(payload: object): string { @@ -113,7 +120,9 @@ describe("plugin.xai", () => { test("returns no options unless stored auth is OAuth and exposes methods in order", async () => { const hooks = await XaiAuthPlugin({} as any) expect(await hooks.auth!.loader!(async () => ({ type: "api", key: "sk-test" }), {} as any)).toEqual({}) - expect(await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any)).toEqual({}) + expect( + await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any), + ).toEqual({}) expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([ ["oauth", "xAI Grok OAuth (SuperGrok Subscription)"], ["oauth", "xAI Grok OAuth (Headless / Remote / VPS)"], @@ -152,12 +161,17 @@ describe("plugin.xai", () => { captured.push(request.headers) return new Response("{}", { status: 200 }) }) - const opts = await (await XaiAuthPlugin(input)).auth!.loader!( + const opts = await ( + await XaiAuthPlugin(input) + ).auth!.loader!( async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }), {} as any, ) - const objHeaders: Record = { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" } + const objHeaders: Record = { + Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, + "x-trace": "plain-object", + } await opts.fetch!(new URL("/chat/completions", server.url), { headers: objHeaders }) expect(objHeaders).toEqual({ Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "x-trace": "plain-object" }) @@ -170,7 +184,11 @@ describe("plugin.xai", () => { await opts.fetch!(new URL("/chat/completions", server.url), { headers: headersInstance }) expect(headersInstance.get("x-trace")).toBe("headers-instance") - expect(captured.map((headers) => headers.get("x-trace"))).toEqual(["plain-object", "tuple-array", "headers-instance"]) + expect(captured.map((headers) => headers.get("x-trace"))).toEqual([ + "plain-object", + "tuple-array", + "headers-instance", + ]) for (const headers of captured) { expect(headers.get("authorization")).toBe("Bearer tok") expect(headers.get("user-agent")).toMatch(/^opencode\//) @@ -184,14 +202,20 @@ describe("plugin.xai", () => { captured.push(request.headers) return new Response("{}", { status: 200 }) }) - const opts = await (await XaiAuthPlugin(input)).auth!.loader!( + const opts = await ( + await XaiAuthPlugin(input) + ).auth!.loader!( async () => ({ type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 }), {} as any, ) await opts.fetch!( new Request(new URL("/chat/completions", server.url), { - headers: { Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, "content-type": "application/json", "x-trace": "request" }, + headers: { + Authorization: `Bearer ${OAUTH_DUMMY_KEY}`, + "content-type": "application/json", + "x-trace": "request", + }, }), { headers: { "x-trace": "init", "x-extra": "yes" } }, ) @@ -210,7 +234,9 @@ describe("plugin.xai", () => { return new Response("{}", { status: 200 }) }) let firstCall = true - const opts = await (await XaiAuthPlugin(input)).auth!.loader!(async () => { + const opts = await ( + await XaiAuthPlugin(input) + ).auth!.loader!(async () => { if (firstCall) { firstCall = false return { type: "oauth", access: "tok", refresh: "rt", expires: Date.now() + 3600_000 } @@ -239,10 +265,9 @@ describe("plugin.xai", () => { apiRequests.push(request.headers) return new Response("{}", { status: 200 }) }) - const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( - async () => ({ type: "oauth" as const, access: "old", refresh: "rt-old", expires: 0 }), - {} as any, - ) + const opts = await ( + await XaiAuthPlugin(input, serverOptions(server)) + ).auth!.loader!(async () => ({ type: "oauth" as const, access: "old", refresh: "rt-old", expires: 0 }), {} as any) await Promise.all([ opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }), @@ -250,7 +275,10 @@ describe("plugin.xai", () => { ]) expect(tokenRequests).toBe(1) - expect(apiRequests.map((headers) => headers.get("authorization"))).toEqual(["Bearer new-access", "Bearer new-access"]) + expect(apiRequests.map((headers) => headers.get("authorization"))).toEqual([ + "Bearer new-access", + "Bearer new-access", + ]) expect(setCalls).toHaveLength(1) expect((setCalls[0].body as any).refresh).toBe("rt-new") }) @@ -264,14 +292,24 @@ describe("plugin.xai", () => { const refreshToken = new URLSearchParams(await request.text()).get("refresh_token")! tokenRequests.push(refreshToken) await new Promise((resolve) => setTimeout(resolve, 20)) - return Response.json({ access_token: `access-${refreshToken}`, refresh_token: `next-${refreshToken}`, expires_in: 3600 }) + return Response.json({ + access_token: `access-${refreshToken}`, + refresh_token: `next-${refreshToken}`, + expires_in: 3600, + }) } apiRequests.push(request.headers.get("authorization")!) return new Response("{}", { status: 200 }) }) const hooks = await XaiAuthPlugin(input, serverOptions(server)) - const first = await hooks.auth!.loader!(async () => ({ type: "oauth", access: "old-a", refresh: "rt-a", expires: 0 }), {} as any) - const second = await hooks.auth!.loader!(async () => ({ type: "oauth", access: "old-b", refresh: "rt-b", expires: 0 }), {} as any) + const first = await hooks.auth!.loader!( + async () => ({ type: "oauth", access: "old-a", refresh: "rt-a", expires: 0 }), + {} as any, + ) + const second = await hooks.auth!.loader!( + async () => ({ type: "oauth", access: "old-b", refresh: "rt-b", expires: 0 }), + {} as any, + ) await Promise.all([ first.fetch!(new URL("/chat/completions", server.url), { headers: {} }), @@ -289,17 +327,22 @@ describe("plugin.xai", () => { if (url.pathname === "/oauth2/token") { tokenRequests++ if (tokenRequests === 2) return new Response("temporarily unavailable", { status: 503 }) - return Response.json({ access_token: `new-${tokenRequests}`, refresh_token: `rt-${tokenRequests}`, expires_in: 3600 }) + return Response.json({ + access_token: `new-${tokenRequests}`, + refresh_token: `rt-${tokenRequests}`, + expires_in: 3600, + }) } return new Response("{}", { status: 200 }) }) - const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( - async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), - {} as any, - ) + const opts = await ( + await XaiAuthPlugin(input, serverOptions(server)) + ).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), {} as any) await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }) - await expect(opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })).rejects.toThrow(/xAI token refresh failed \(503\)/) + await expect(opts.fetch!(new URL("/chat/completions", server.url), { headers: {} })).rejects.toThrow( + /xAI token refresh failed \(503\)/, + ) await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }) expect(tokenRequests).toBe(3) }) @@ -312,10 +355,9 @@ describe("plugin.xai", () => { captured.push(request.headers) return new Response("{}", { status: 200 }) }) - const opts = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( - async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), - {} as any, - ) + const opts = await ( + await XaiAuthPlugin(input, serverOptions(server)) + ).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt-old", expires: 0 }), {} as any) const resp = await opts.fetch!(new URL("/chat/completions", server.url), { headers: {} }) expect(resp.status).toBe(200) @@ -333,7 +375,9 @@ describe("plugin.xai", () => { } return new Response("{}", { status: 200 }) }) - const fresh = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + const fresh = await ( + await XaiAuthPlugin(input, serverOptions(server)) + ).auth!.loader!( async () => ({ type: "oauth", access: makeJwt({ exp: Math.floor(Date.now() / 1000) + 24 * 3600 }), @@ -345,7 +389,9 @@ describe("plugin.xai", () => { await fresh.fetch!(new URL("/chat/completions", server.url), { headers: {} }) expect(tokenRequests).toBe(0) - const jwtExpiring = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( + const jwtExpiring = await ( + await XaiAuthPlugin(input, serverOptions(server)) + ).auth!.loader!( async () => ({ type: "oauth", access: makeJwt({ exp: Math.floor((Date.now() + 30_000) / 1000) }), @@ -354,10 +400,9 @@ describe("plugin.xai", () => { }), {} as any, ) - const missingExpires = await (await XaiAuthPlugin(input, serverOptions(server))).auth!.loader!( - async () => ({ type: "oauth", access: "opaque-token", refresh: "rt", expires: 0 }), - {} as any, - ) + const missingExpires = await ( + await XaiAuthPlugin(input, serverOptions(server)) + ).auth!.loader!(async () => ({ type: "oauth", access: "opaque-token", refresh: "rt", expires: 0 }), {} as any) await jwtExpiring.fetch!(new URL("/chat/completions", server.url), { headers: {} }) await missingExpires.fetch!(new URL("/chat/completions", server.url), { headers: {} }) expect(tokenRequests).toBe(2) @@ -366,10 +411,9 @@ describe("plugin.xai", () => { test("network failure during refresh surfaces the underlying fetch error", async () => { const { input } = makeInput() - const opts = await (await XaiAuthPlugin(input, { tokenUrl: "http://127.0.0.1:9/oauth2/token" })).auth!.loader!( - async () => ({ type: "oauth", access: "old", refresh: "rt", expires: 0 }), - {} as any, - ) + const opts = await ( + await XaiAuthPlugin(input, { tokenUrl: "http://127.0.0.1:9/oauth2/token" }) + ).auth!.loader!(async () => ({ type: "oauth", access: "old", refresh: "rt", expires: 0 }), {} as any) await expect(opts.fetch!("https://api.x.ai/v1/chat/completions", { headers: {} })).rejects.toThrow() }) @@ -394,7 +438,10 @@ describe("plugin.xai", () => { return new Response("unexpected request", { status: 500 }) }) const hooks = await XaiAuthPlugin({} as any, serverOptions(server)) - const headless = hooks.auth!.methods.find((m): m is Extract => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)")! + const headless = hooks.auth!.methods.find( + (m): m is Extract => + m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + )! const result = await headless.authorize!() expect(result.method).toBe("auto") @@ -407,12 +454,17 @@ describe("plugin.xai", () => { test("authorize falls back to verification_uri when verification_uri_complete is absent", async () => { using server = makeServer((_, url) => { if (url.pathname === "/oauth2/device/code") { - return Response.json({ device_code: "DEVICE-2", user_code: "WXYZ-9876", verification_uri: "https://x.ai/device" }) + return Response.json({ + device_code: "DEVICE-2", + user_code: "WXYZ-9876", + verification_uri: "https://x.ai/device", + }) } return new Response("unexpected request", { status: 500 }) }) const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( - (m): m is Extract => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + (m): m is Extract => + m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", )! expect((await headless.authorize!()).url).toBe("https://x.ai/device") }) @@ -436,8 +488,12 @@ describe("plugin.xai", () => { expect(parsed.get("scope")).toContain("offline_access") expect(parsed.get("scope")).toContain("grok-cli:access") expect(parsed.get("scope")).toContain("api:access") - await expect(requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() })).rejects.toThrow(/429.*rate limited/) - await expect(requestDeviceCode({ deviceAuthorizationUrl: new URL("/missing", server.url).toString() })).rejects.toThrow(/missing device_code/) + await expect( + requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() }), + ).rejects.toThrow(/429.*rate limited/) + await expect( + requestDeviceCode({ deviceAuthorizationUrl: new URL("/missing", server.url).toString() }), + ).rejects.toThrow(/missing device_code/) }) test("pollDeviceCodeToken resolves on success and posts the device-code grant", async () => { @@ -487,7 +543,13 @@ describe("plugin.xai", () => { using server = makeServer(() => Response.json(body, { status: 500 })) await expect( pollDeviceCodeToken( - { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 600 }, + { + device_code: "DC", + user_code: "UC", + verification_uri: "https://x.ai/device", + interval: 1, + expires_in: 600, + }, { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() }, ), ).rejects.toThrow(error) @@ -498,7 +560,11 @@ describe("plugin.xai", () => { await expect( pollDeviceCodeToken( { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: 1 }, - { sleep: async () => {}, now: () => 1_000_000 + tick++ * 600, tokenUrl: new URL("/oauth2/token", pending.url).toString() }, + { + sleep: async () => {}, + now: () => 1_000_000 + tick++ * 600, + tokenUrl: new URL("/oauth2/token", pending.url).toString(), + }, ), ).rejects.toThrow(/timed out/) }) @@ -514,7 +580,13 @@ describe("plugin.xai", () => { }) const sleeps: number[] = [] await pollDeviceCodeToken( - { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: bad as number, expires_in: 600 }, + { + device_code: "DC", + user_code: "UC", + verification_uri: "https://x.ai/device", + interval: bad as number, + expires_in: 600, + }, { sleep: async (ms) => void sleeps.push(ms), tokenUrl: new URL("/oauth2/token", server.url).toString() }, ) expect(sleeps[0]).toBe(8_000) @@ -525,7 +597,13 @@ describe("plugin.xai", () => { expect( ( await pollDeviceCodeToken( - { device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 1, expires_in: bad as number }, + { + device_code: "DC", + user_code: "UC", + verification_uri: "https://x.ai/device", + interval: 1, + expires_in: bad as number, + }, { sleep: async () => {}, tokenUrl: new URL("/oauth2/token", server.url).toString() }, ) ).access_token, @@ -536,14 +614,21 @@ describe("plugin.xai", () => { test("device-code authorize callback returns failed when polling errors", async () => { using server = makeServer((_, url) => { if (url.pathname === "/oauth2/device/code") { - return Response.json({ device_code: "DC", user_code: "UC", verification_uri: "https://x.ai/device", interval: 0, expires_in: 600 }) + return Response.json({ + device_code: "DC", + user_code: "UC", + verification_uri: "https://x.ai/device", + interval: 0, + expires_in: 600, + }) } return Response.json({ error: "access_denied" }, { status: 400 }) }) const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( - (m): m is Extract => m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + (m): m is Extract => + m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", )! - expect(await (await headless.authorize!() as any).callback()).toEqual({ type: "failed" }) + expect(await ((await headless.authorize!()) as any).callback()).toEqual({ type: "failed" }) }) }) })