From 50c5218bca627c2a48468a4052cba552e9ed3e98 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:53:14 -0500 Subject: [PATCH] fix(core): clarify integration auth errors (#44786) Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com> Co-authored-by: rekram1-node --- packages/core/src/plugin/provider/openai.ts | 73 +++++++++++++++-- packages/server/src/handlers/integration.ts | 5 +- packages/server/test/fetch.test.ts | 86 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 8fa5354bb0c..2e9f9c3a9c4 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,6 +1,7 @@ import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" import { define } from "@opencode-ai/plugin/effect/plugin" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" +import type { Server } from "node:http" import { App } from "../../app.js" import { Credential } from "../../credential.js" import { Bus } from "../../bus.js" @@ -12,6 +13,9 @@ import type { PluginInternal } from "../internal.js" const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" const issuer = "https://auth.openai.com" const callbackPort = 1455 +const callbackFallbackPort = 1457 +const callbackBindAttempts = 10 +const callbackBindRetryDelay = 200 const pollingSafetyMargin = 3000 const codexBaseURL = "https://chatgpt.com/backend-api/codex" const browserMethodID = Integration.MethodID.make("chatgpt-browser") @@ -55,11 +59,10 @@ const browser = (app: App.Info) => const pkce = yield* Effect.promise(generatePKCE) const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const code = yield* Deferred.make() - const redirect = `http://localhost:${callbackPort}/auth/callback` // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. const { createServer } = yield* Effect.promise(() => import("node:http")) const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + const url = new URL(request.url ?? "/", "http://localhost") if (url.pathname !== "/auth/callback") { response.writeHead(404).end("Not found") return @@ -86,11 +89,9 @@ const browser = (app: App.Info) => .writeHead(200, { "Content-Type": "text/html" }) .end(OauthCallbackPage.success({ provider: "ChatGPT" })) }) - yield* Effect.callback((resume) => { - server.once("error", (error) => resume(Effect.fail(error))) - server.listen(callbackPort, "localhost", () => resume(Effect.void)) - }) + const port = yield* listen(server) yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + const redirect = `http://localhost:${port}/auth/callback` return { mode: "auto" as const, url: authorizeURL(redirect, pkce, state), @@ -104,6 +105,66 @@ const browser = (app: App.Info) => refresh: (value) => refresh(browserMethodID, value, app), }) satisfies IntegrationOAuthMethodRegistration +function listen(server: Server) { + return bind(server, callbackPort).pipe( + Effect.as(callbackPort), + Effect.catchIf(addressInUse, () => + cancel(callbackPort).pipe( + Effect.ignore, + Effect.andThen(Effect.sleep(callbackBindRetryDelay)), + Effect.andThen(bindWithRetry(server, callbackPort, callbackBindAttempts - 1)), + Effect.as(callbackPort), + Effect.catchIf(addressInUse, () => + bindWithRetry(server, callbackFallbackPort, callbackBindAttempts).pipe( + Effect.as(callbackFallbackPort), + Effect.catchIf(addressInUse, () => + Effect.fail( + new Error( + `OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`, + ), + ), + ), + ), + ), + ), + ), + ) +} + +function bindWithRetry(server: Server, port: number, attempts: number): Effect.Effect { + return bind(server, port).pipe( + Effect.catchIf( + (error) => addressInUse(error) && attempts > 1, + () => Effect.sleep(callbackBindRetryDelay).pipe(Effect.andThen(bindWithRetry(server, port, attempts - 1))), + ), + ) +} + +function bind(server: Server, port: number) { + return Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)) + server.once("error", onError) + server.listen(port, "localhost", () => { + server.off("error", onError) + resume(Effect.void) + }) + }) +} + +function cancel(port: number) { + return Effect.tryPromise({ + try: (signal) => + fetch(`http://localhost:${port}/cancel`, { + signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]), + }), + catch: (cause) => cause, + }) +} + +function addressInUse(error: Error) { + return "code" in error && error.code === "EADDRINUSE" +} + const headless = (app: App.Info) => ({ integrationID: Integration.ID.make("openai"), diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 0fa99955dee..9143fb91377 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -9,9 +9,10 @@ import { WellKnown } from "@opencode-ai/core/wellknown" const authorize = (effect: Effect.Effect) => effect.pipe( Effect.mapError( - () => + (error) => new InvalidRequestError({ - message: "Authentication failed", + message: + error.cause instanceof Error && error.cause.message.trim() ? error.cause.message : "Authentication failed", kind: "integration_authorization", }), ), diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index d1943dd1588..79432d6022d 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { createServer, type Server } from "node:http" import { Workspace } from "@opencode-ai/core/workspace" import { Effect } from "effect" import { it } from "../../core/test/lib/effect" @@ -10,6 +11,29 @@ const options = { fs: { filewatcher: false }, } as const +type Handler = (request: Request) => Promise + +function occupy(server: Server, port: number) { + return Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(port, "localhost", () => resume(Effect.void)) + }) +} + +const ready = (handler: Handler) => + Effect.promise(() => handler(new Request("http://opencode.local/api/model/default"))) + +const connectOpenAI = (handler: Handler) => + Effect.promise(() => + handler( + new Request("http://opencode.local/api/integration/openai/connect/oauth", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ methodID: "chatgpt-browser" }), + }), + ), + ) + it.live("serves the HttpApi and enforces Basic auth like the Node server", () => Effect.gen(function* () { const handler = yield* ServerFetch.make({ ...options, password: "secret" }) @@ -53,6 +77,68 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c }).pipe(Effect.scoped), ) +it.live("cancels a stale OpenAI OAuth callback server before falling back", () => + Effect.gen(function* () { + const requests: string[] = [] + const blocker = createServer((request, response) => { + requests.push(request.url ?? "") + response.end("cancelled", () => blocker.close()) + }) + yield* occupy(blocker, 1455) + yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close())) + const handler = yield* ServerFetch.make(options) + yield* ready(handler) + const response = yield* connectOpenAI(handler) + + expect(response.status).toBe(200) + expect(requests).toContain("/cancel") + const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } } + expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback") + }).pipe(Effect.scoped), +) + +it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () => + Effect.gen(function* () { + const requests: string[] = [] + const blocker = createServer((request, response) => { + requests.push(request.url ?? "") + response.end("still running") + }) + yield* occupy(blocker, 1455) + yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close())) + const handler = yield* ServerFetch.make(options) + yield* ready(handler) + const response = yield* connectOpenAI(handler) + + expect(response.status).toBe(200) + expect(requests).toContain("/cancel") + const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } } + expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback") + }).pipe(Effect.scoped), +) + +it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () => + Effect.gen(function* () { + const preferred = createServer((_request, response) => response.end("still running")) + const fallback = createServer() + yield* occupy(preferred, 1455) + yield* occupy(fallback, 1457) + yield* Effect.addFinalizer(() => Effect.sync(() => preferred.close())) + yield* Effect.addFinalizer(() => Effect.sync(() => fallback.close())) + const handler = yield* ServerFetch.make(options) + yield* ready(handler) + const response = yield* connectOpenAI(handler) + + expect(response.status).toBe(400) + expect(yield* Effect.promise(() => response.json())).toEqual({ + _tag: "InvalidRequestError", + message: + "OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.", + kind: "integration_authorization", + }) + }).pipe(Effect.scoped), +) + it.live("treats destroying a missing workspace as success", () => Effect.gen(function* () { const handler = yield* ServerFetch.make(options)