fix(core): make xAI OAuth device-only (#40538)

This commit is contained in:
Aiden Cline 2026-08-04 22:55:31 -05:00 committed by GitHub
parent c74a0d8529
commit daa998f9c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 55 additions and 137 deletions

View file

@ -7,6 +7,7 @@ import { Agent } from "../agent"
import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { Credential } from "../credential"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigProviderPlugin } from "../config/plugin/provider"
@ -67,6 +68,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const bus = yield* Bus.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(Bus.Service, bus),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),

View file

@ -1,30 +1,18 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Clock, Deferred, Effect, Option, Schema } from "effect"
import { Clock, Effect, Option, Schema } from "effect"
import { App } from "../../app"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { OauthCallbackPage } from "../../oauth/page"
const clientID = "b1a00492-073a-47ea-816f-4c329264a828"
const issuer = "https://auth.x.ai/oauth2"
const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code"
const scope = "openid profile email offline_access grok-cli:access api:access"
const callbackHost = "127.0.0.1"
const callbackPort = 56121
const callbackPath = "/callback"
const redirectURI = `http://${callbackHost}:${callbackPort}${callbackPath}`
const pollingSafetyMargin = 3000
const corsOrigins = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
const browserMethodID = Integration.MethodID.make("browser")
const deviceMethodID = Integration.MethodID.make("device")
type Pkce = {
verifier: string
challenge: string
}
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
@ -47,80 +35,12 @@ const DeviceError = Schema.Struct({
})
const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError))
const browser = (app: App.Info) => ({
integrationID: Integration.ID.make("xai"),
method: {
id: browserMethodID,
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = randomString(32)
const code = yield* Deferred.make<string, Error>()
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", redirectURI)
const origin = request.headers.origin
if (origin && corsOrigins.has(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin)
response.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
response.setHeader("Access-Control-Allow-Headers", "Content-Type")
response.setHeader("Access-Control-Allow-Private-Network", "true")
response.setHeader("Vary", "Origin")
}
if (request.method === "OPTIONS") {
response.writeHead(204).end()
return
}
if (url.pathname !== callbackPath) {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(error, { provider: "xAI" }))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response
.writeHead(400, { "Content-Type": "text/html" })
.end(OauthCallbackPage.error(message, { provider: "xAI" }))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "xAI" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, callbackHost, () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: authorizeURL(pkce, state, randomString(32)),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, pkce, app)),
Effect.flatMap((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(browserMethodID, Credential.OAuth.make({ ...value, methodID: browserMethodID }), app),
}) satisfies IntegrationOAuthMethodRegistration
const device = (app: App.Info) => ({
integrationID: Integration.ID.make("xai"),
method: {
id: deviceMethodID,
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
label: "SuperGrok Subscription",
},
authorize: () =>
request(
@ -128,7 +48,7 @@ const device = (app: App.Info) => ({
{
method: "POST",
headers: headers(app),
body: new URLSearchParams({ client_id: clientID, scope }).toString(),
body: new URLSearchParams({ client_id: clientID, scope, referrer: "opencode" }).toString(),
},
Device,
).pipe(
@ -153,35 +73,28 @@ const device = (app: App.Info) => ({
export const XAIPlugin = define({
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
const credentials = yield* Credential.Service
yield* Effect.forEach(
yield* credentials.list(Integration.ID.make("xai")),
(credential) => {
if (credential.value.type !== "oauth" || credential.value.methodID !== browserMethodID) return Effect.void
return credentials.update(credential.id, {
value: Credential.OAuth.make({ ...credential.value, methodID: deviceMethodID }),
})
},
{ discard: true },
)
yield* ctx.integration.transform((draft) => {
draft.update("xai", (integration) => {
integration.name = "xAI"
})
draft.method.update(browser(ctx.app))
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
}),
})
function exchange(code: string, pkce: Pkce, app: App.Info) {
return request(
`${issuer}/token`,
{
method: "POST",
headers: headers(app),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectURI,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
},
Token,
)
}
function refresh(methodID: Integration.MethodID, value: Credential.OAuth, app: App.Info) {
return request(
`${issuer}/token`,
@ -310,31 +223,3 @@ function positiveSeconds(value: unknown, fallback: number) {
const seconds = Number(value)
return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback
}
async function generatePKCE(): Promise<Pkce> {
const verifier = randomString(64)
const challenge = Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString(
"base64url",
)
return { verifier, challenge }
}
function randomString(length: number) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return Array.from(crypto.getRandomValues(new Uint8Array(length)), (byte) => chars[byte % chars.length]).join("")
}
function authorizeURL(pkce: Pkce, state: string, nonce: string) {
return `${issuer}/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirectURI,
scope,
code_challenge: pkce.challenge,
code_challenge_method: "S256",
state,
nonce,
plan: "generic",
referrer: "opencode",
}).toString()}`
}

View file

@ -10,6 +10,7 @@ import { Catalog } from "../catalog"
import { Command } from "../command"
import { Config } from "../config"
import { ConfigPlugin } from "../config/plugin"
import { Credential } from "../credential"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Bus } from "../bus"
@ -317,6 +318,7 @@ export const node = makeLocationNode({
Catalog.node,
Command.node,
Config.node,
Credential.node,
Bus.node,
FileMutation.node,
Formatter.node,

View file

@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@ -16,25 +17,52 @@ const addPlugin = Effect.fn(function* () {
})
describe("XAIPlugin", () => {
it.effect("registers browser OAuth, device OAuth, and API key methods", () =>
it.effect("registers device OAuth and API key methods", () =>
Effect.gen(function* () {
yield* addPlugin()
const integrations = yield* Integration.Service
const integration = yield* integrations.get(Integration.ID.make("xai"))
expect(integration?.name).toBe("xAI")
expect(integration?.methods).toEqual([
{
id: Integration.MethodID.make("browser"),
type: "oauth",
label: "xAI Grok OAuth (SuperGrok Subscription)",
},
{
id: Integration.MethodID.make("device"),
type: "oauth",
label: "xAI Grok OAuth (Headless / Remote / VPS)",
label: "SuperGrok Subscription",
},
{ type: "key", label: "Manually enter API Key" },
])
}),
)
it.effect("migrates browser OAuth credentials to the device method", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const original = yield* credentials.create({
integrationID: Integration.ID.make("xai"),
label: "personal",
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("browser"),
access: "access",
refresh: "refresh",
expires: 123,
metadata: { account: "account" },
}),
})
yield* addPlugin()
expect(yield* credentials.get(original.id)).toEqual({
...original,
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "access",
refresh: "refresh",
expires: 123,
metadata: { account: "account" },
}),
})
}),
)
})