diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 3327bef396f..e99191cb72d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/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), diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 5d03bb6bad7..af5b2202f64 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/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() - 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((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 { - 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()}` -} diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 9472637343c..160528b8b8d 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/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, diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index d9227bd5aa2..7eb4a20a30d 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/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" }, + }), + }) + }), + ) })