Browse Source

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

Aiden Cline 5 days ago
parent
commit
daa998f9c3

+ 3 - 0
packages/core/src/plugin/internal.ts

@@ -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),

+ 15 - 130
packages/core/src/plugin/provider/xai.ts

@@ -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()}`
-}

+ 2 - 0
packages/core/src/plugin/supervisor.ts

@@ -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,

+ 35 - 7
packages/core/test/plugin/provider-xai.test.ts

@@ -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" },
+        }),
+      })
+    }),
+  )
 })